mcp-for-i 0.2.0 → 0.3.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 +10 -0
- package/dist/controlplane/commands.js +38 -2
- package/dist/controlplane/server.js +58 -2
- package/dist/controlplane/serviceManager.js +35 -1
- package/dist/controlplane/ui.js +42 -1
- package/dist/ibmi/tn5250/client.js +28 -4
- package/dist/ibmi/tn5250/ebcdic.js +25 -6
- package/dist/ibmi/tn5250/ebcdicTables.js +27 -0
- package/dist/ibmi/tn5250/screen.js +206 -0
- package/dist/index.js +36 -2
- package/dist/mcp/modules/tn5250.js +302 -17
- package/dist/mcp/modules/tn5250App.js +194 -0
- package/dist/mcp/modules/tn5250Font.js +8 -0
- package/dist/mcp/modules/tn5250Live.js +221 -0
- package/dist/mcp/modules/tn5250LivePage.js +480 -0
- package/dist/mcp/tools.js +20 -1
- package/docs/capabilities.md +264 -263
- package/docs/configuration.md +4 -0
- package/docs/tools.md +7 -3
- package/package.json +56 -56
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ From an npm install or a git checkout build, these commands are the supported co
|
|
|
50
50
|
```bash
|
|
51
51
|
mcp-for-i control
|
|
52
52
|
mcp-for-i control status
|
|
53
|
+
mcp-for-i control stop
|
|
54
|
+
mcp-for-i control restart
|
|
53
55
|
mcp-for-i control enable
|
|
54
56
|
mcp-for-i control disable
|
|
55
57
|
```
|
|
@@ -59,11 +61,15 @@ Equivalent direct control-plane binary commands:
|
|
|
59
61
|
```bash
|
|
60
62
|
mcp-for-i-control open
|
|
61
63
|
mcp-for-i-control status
|
|
64
|
+
mcp-for-i-control stop
|
|
65
|
+
mcp-for-i-control restart
|
|
62
66
|
mcp-for-i-control enable
|
|
63
67
|
mcp-for-i-control disable
|
|
64
68
|
mcp-for-i-control serve
|
|
65
69
|
```
|
|
66
70
|
|
|
71
|
+
`stop` shuts down the running control plane (it works for one started by `control`, `serve` or the background service). If the background service is enabled, the control plane starts again at the next login; `disable` turns that off. `restart` stops it and starts it again in the background.
|
|
72
|
+
|
|
67
73
|
Behavior by environment:
|
|
68
74
|
- Windows: `enable` registers a Startup entry.
|
|
69
75
|
- macOS: `enable` registers a LaunchAgent.
|
|
@@ -212,6 +218,10 @@ The existing member tools remain compatible: an unchanged `ibmi.qsys.members.lis
|
|
|
212
218
|
|
|
213
219
|
Source-date reads use an ephemeral `QTEMP` alias in the same database job. If that path is unavailable, the server falls back to a complete UTF-8 `CPYTOSTMF` read; it never reconstructs a member from search matches.
|
|
214
220
|
|
|
221
|
+
## 5250 Green Screen
|
|
222
|
+
|
|
223
|
+
`ibmi.tn5250.*` gives the assistant a real 5250 session (a built-in TN5250 client, nothing to install on the IBM i). While a session is open the MCP server also serves a live view on `http://127.0.0.1:3981` (the link, with its token, is in every screen result and on the control plane's Runtime tab): you watch the screen the assistant is driving and can type and click in it yourself. Clients that support MCP Apps draw the same screen inside the chat. See [docs/tools.md](docs/tools.md#tn5250-notes).
|
|
224
|
+
|
|
215
225
|
## IBM i Documentation
|
|
216
226
|
|
|
217
227
|
`ibmi.docs.search` and `ibmi.docs.open` provide a release-aware, cached catalog of allowlisted official IBM documentation. The same catalog and topic content are exposed as MCP resources under `ibmi-docs://`. Documentation results include their official canonical URL, content version/checksum, and observation time. When local coverage is insufficient, search results explicitly recommend a web fallback instead of presenting cached material as exhaustive.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { startControlPlaneServer } from "./server.js";
|
|
2
2
|
import { openUrl } from "./platform.js";
|
|
3
3
|
import { getControlPlaneUrl } from "./serverShared.js";
|
|
4
|
-
import { disableBackgroundService, enableBackgroundService, ensureControlPlaneRunning, getServiceStatus } from "./serviceManager.js";
|
|
4
|
+
import { disableBackgroundService, enableBackgroundService, ensureControlPlaneRunning, getServiceStatus, stopControlPlane } from "./serviceManager.js";
|
|
5
5
|
export async function runControlCommand(args, options) {
|
|
6
6
|
const stderr = options.stderr || process.stderr;
|
|
7
7
|
const command = normalizeCommand(args, options.defaultCommand || "serve");
|
|
@@ -17,6 +17,13 @@ export async function runControlCommand(args, options) {
|
|
|
17
17
|
case "open":
|
|
18
18
|
await openControlUi(stderr, scriptPath, nodePath);
|
|
19
19
|
return;
|
|
20
|
+
case "stop":
|
|
21
|
+
await stop(stderr, scriptPath, nodePath);
|
|
22
|
+
return;
|
|
23
|
+
case "restart":
|
|
24
|
+
await stop(stderr, scriptPath, nodePath);
|
|
25
|
+
await openControlUi(stderr, scriptPath, nodePath, false);
|
|
26
|
+
return;
|
|
20
27
|
case "enable":
|
|
21
28
|
await enableService(stderr, scriptPath, nodePath);
|
|
22
29
|
return;
|
|
@@ -53,6 +60,28 @@ async function serve(stderr) {
|
|
|
53
60
|
process.once("SIGINT", shutdown);
|
|
54
61
|
process.once("SIGTERM", shutdown);
|
|
55
62
|
await server.closed;
|
|
63
|
+
if (await server.shutdownRequested) {
|
|
64
|
+
stderr.write("mcp-for-i control plane stopped\n");
|
|
65
|
+
// Background timers (update checks, client probes) must not keep a stopped server's process alive.
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function stop(stderr, scriptPath, nodePath) {
|
|
70
|
+
const url = getControlPlaneUrl();
|
|
71
|
+
const result = await stopControlPlane(scriptPath, nodePath);
|
|
72
|
+
if (!result.wasRunning) {
|
|
73
|
+
stderr.write(`Control plane is not running at ${url}\n`);
|
|
74
|
+
}
|
|
75
|
+
else if (result.stopped) {
|
|
76
|
+
stderr.write(`Stopped control plane at ${url}\n`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
stderr.write(`Control plane at ${url} did not stop: ${result.detail || "it is still answering"}\n`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
}
|
|
82
|
+
if (result.serviceEnabled) {
|
|
83
|
+
stderr.write(`The background service is still enabled (${result.managedBy}), so it starts again at the next login. Run "mcp-for-i control disable" to turn that off.\n`);
|
|
84
|
+
}
|
|
56
85
|
}
|
|
57
86
|
async function printStatus(stderr, scriptPath, nodePath) {
|
|
58
87
|
const url = getControlPlaneUrl();
|
|
@@ -66,12 +95,17 @@ async function printStatus(stderr, scriptPath, nodePath) {
|
|
|
66
95
|
if (status.detail)
|
|
67
96
|
stderr.write(`Detail: ${status.detail}\n`);
|
|
68
97
|
}
|
|
69
|
-
async function openControlUi(stderr, scriptPath, nodePath) {
|
|
98
|
+
async function openControlUi(stderr, scriptPath, nodePath, openBrowser = true) {
|
|
70
99
|
const url = getControlPlaneUrl();
|
|
71
100
|
const result = await ensureControlPlaneRunning(scriptPath, nodePath);
|
|
72
101
|
if (result.started) {
|
|
73
102
|
stderr.write(`Started control plane at ${url}\n`);
|
|
74
103
|
}
|
|
104
|
+
if (!openBrowser) {
|
|
105
|
+
if (!result.started)
|
|
106
|
+
stderr.write(`Control plane is running at ${url}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
75
109
|
try {
|
|
76
110
|
await openUrl(url);
|
|
77
111
|
stderr.write(`Opening ${url}\n`);
|
|
@@ -99,6 +133,8 @@ function printHelp(stderr) {
|
|
|
99
133
|
stderr.write(`mcp-for-i-control commands:\n`);
|
|
100
134
|
stderr.write(` serve Start control plane server in the foreground\n`);
|
|
101
135
|
stderr.write(` open Start control plane in the background if needed, then open UI\n`);
|
|
136
|
+
stderr.write(` stop Stop the running control plane\n`);
|
|
137
|
+
stderr.write(` restart Stop the control plane, then start it again in the background\n`);
|
|
102
138
|
stderr.write(` status Show running and background-service status\n`);
|
|
103
139
|
stderr.write(` enable Enable background service for this platform\n`);
|
|
104
140
|
stderr.write(` disable Disable background service for this platform\n`);
|
|
@@ -27,11 +27,18 @@ export async function startControlPlaneServer(opts) {
|
|
|
27
27
|
const runtime = new RuntimeService(rootDir);
|
|
28
28
|
const clients = new ClientIntegrationService(rootDir);
|
|
29
29
|
const guard = { token, port, extraHosts: extraAllowedHosts(host) };
|
|
30
|
+
let markShutdown = () => undefined;
|
|
31
|
+
const shutdownRequested = new Promise(resolve => { markShutdown = resolve; });
|
|
32
|
+
const requestShutdown = () => {
|
|
33
|
+
markShutdown(true);
|
|
34
|
+
server.close();
|
|
35
|
+
server.closeAllConnections();
|
|
36
|
+
};
|
|
30
37
|
const server = http.createServer(async (req, res) => {
|
|
31
38
|
try {
|
|
32
39
|
if (!checkRequest(req, res, guard))
|
|
33
40
|
return;
|
|
34
|
-
await routeRequest(req, res, { store, connections, runtime, clients, token });
|
|
41
|
+
await routeRequest(req, res, { store, connections, runtime, clients, token, requestShutdown });
|
|
35
42
|
}
|
|
36
43
|
catch (err) {
|
|
37
44
|
if (res.headersSent) {
|
|
@@ -49,12 +56,16 @@ export async function startControlPlaneServer(opts) {
|
|
|
49
56
|
server.listen(port, host, () => resolve());
|
|
50
57
|
});
|
|
51
58
|
guard.port = server.address().port;
|
|
52
|
-
const closed = new Promise(resolve => server.once("close", () =>
|
|
59
|
+
const closed = new Promise(resolve => server.once("close", () => {
|
|
60
|
+
markShutdown(false);
|
|
61
|
+
resolve();
|
|
62
|
+
}));
|
|
53
63
|
return {
|
|
54
64
|
host,
|
|
55
65
|
port: guard.port,
|
|
56
66
|
token,
|
|
57
67
|
closed,
|
|
68
|
+
shutdownRequested,
|
|
58
69
|
close: () => new Promise((resolve, reject) => {
|
|
59
70
|
server.close(err => (err ? reject(err) : resolve()));
|
|
60
71
|
server.closeIdleConnections();
|
|
@@ -170,10 +181,20 @@ async function routeRequest(req, res, services) {
|
|
|
170
181
|
sendJson(res, 200, { settings });
|
|
171
182
|
return;
|
|
172
183
|
}
|
|
184
|
+
if (method === "POST" && pathname === "/api/shutdown") {
|
|
185
|
+
// Answer first, then stop listening and drop the remaining connections.
|
|
186
|
+
res.once("finish", () => setImmediate(services.requestShutdown));
|
|
187
|
+
sendJson(res, 200, { stopping: true });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
173
190
|
if (method === "GET" && pathname === "/api/sessions") {
|
|
174
191
|
sendJson(res, 200, { snapshot: await readSessionSnapshot() });
|
|
175
192
|
return;
|
|
176
193
|
}
|
|
194
|
+
if (method === "GET" && pathname === "/api/tn5250/live") {
|
|
195
|
+
sendJson(res, 200, { views: await readLiveViews() });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
177
198
|
if (method === "GET" && pathname === "/api/runtime/status") {
|
|
178
199
|
sendJson(res, 200, { jobs: services.runtime.getStatus() });
|
|
179
200
|
return;
|
|
@@ -375,6 +396,41 @@ function parseNumberSetting(input, min, max, key) {
|
|
|
375
396
|
}
|
|
376
397
|
return normalized;
|
|
377
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Live 5250 views: each MCP process that serves one writes a file with its
|
|
401
|
+
* address and sessions. Entries whose server no longer answers are dropped.
|
|
402
|
+
*/
|
|
403
|
+
async function readLiveViews() {
|
|
404
|
+
const dir = path.join(getConfigDir(), "tn5250-live");
|
|
405
|
+
let names = [];
|
|
406
|
+
try {
|
|
407
|
+
names = (await fsp.readdir(dir)).filter(name => name.endsWith(".json"));
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
const views = [];
|
|
413
|
+
for (const name of names) {
|
|
414
|
+
const file = path.join(dir, name);
|
|
415
|
+
try {
|
|
416
|
+
const entry = JSON.parse(await fsp.readFile(file, "utf8"));
|
|
417
|
+
if (!entry || typeof entry.url !== "string" || typeof entry.port !== "number")
|
|
418
|
+
throw new Error("bad entry");
|
|
419
|
+
const controller = new AbortController();
|
|
420
|
+
const timer = setTimeout(() => controller.abort(), 1000);
|
|
421
|
+
const alive = await fetch(`http://127.0.0.1:${entry.port}/api/sessions?t=${new URL(entry.url).searchParams.get("t")}`, { signal: controller.signal })
|
|
422
|
+
.then(r => r.ok).catch(() => false);
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
if (!alive)
|
|
425
|
+
throw new Error("gone");
|
|
426
|
+
views.push({ pid: entry.pid, port: entry.port, url: entry.url, sessions: entry.sessions || [], updatedAt: entry.updatedAt });
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
await fsp.rm(file, { force: true }).catch(() => undefined);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return views;
|
|
433
|
+
}
|
|
378
434
|
async function readSessionSnapshot() {
|
|
379
435
|
const snapshotPath = path.join(getConfigDir(), "session-state.json");
|
|
380
436
|
try {
|
|
@@ -4,7 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
5
|
import { getConfigDir } from "../config/store.js";
|
|
6
6
|
import { getPlatformDescriptor } from "./platform.js";
|
|
7
|
-
import { getControlPlanePort, isControlPlaneRunning } from "./serverShared.js";
|
|
7
|
+
import { getControlPlaneAuthHeaders, getControlPlanePort, getControlPlaneUrl, isControlPlaneRunning } from "./serverShared.js";
|
|
8
8
|
const SERVICE_ID = "mcp-for-i-controlplane";
|
|
9
9
|
const MAC_LABEL = "com.mcpfori.controlplane";
|
|
10
10
|
const RC_BEGIN = "# >>> mcp-for-i control >>>";
|
|
@@ -87,6 +87,40 @@ export async function disableBackgroundService(scriptPath, nodePath = process.ex
|
|
|
87
87
|
}
|
|
88
88
|
return getServiceStatus(scriptPath, nodePath);
|
|
89
89
|
}
|
|
90
|
+
export async function stopControlPlane(scriptPath, nodePath = process.execPath, timeoutMs = 10000) {
|
|
91
|
+
const status = await getServiceStatus(scriptPath, nodePath).catch(() => undefined);
|
|
92
|
+
const base = { serviceEnabled: Boolean(status?.enabled), managedBy: status?.managedBy };
|
|
93
|
+
if (!(await isControlPlaneRunning()))
|
|
94
|
+
return { ...base, wasRunning: false, stopped: true };
|
|
95
|
+
if (process.platform === "darwin" && (await pathExists(getMacLaunchAgentPath()))) {
|
|
96
|
+
await runQuiet("launchctl", ["unload", getMacLaunchAgentPath()]);
|
|
97
|
+
}
|
|
98
|
+
else if (process.platform === "linux" && (await canUseSystemdUser()) && (await isSystemdActive())) {
|
|
99
|
+
await runQuiet("systemctl", ["--user", "stop", SERVICE_ID]);
|
|
100
|
+
}
|
|
101
|
+
let detail;
|
|
102
|
+
if (await isControlPlaneRunning()) {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${getControlPlaneUrl()}/api/shutdown`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { ...getControlPlaneAuthHeaders(), "Content-Type": "application/json" },
|
|
107
|
+
body: "{}"
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
detail = `shutdown request failed with HTTP ${res.status}`;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// the server may drop the connection while it stops
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const deadline = Date.now() + timeoutMs;
|
|
117
|
+
while (Date.now() < deadline) {
|
|
118
|
+
if (!(await isControlPlaneRunning()))
|
|
119
|
+
return { ...base, wasRunning: true, stopped: true };
|
|
120
|
+
await wait(250);
|
|
121
|
+
}
|
|
122
|
+
return { ...base, wasRunning: true, stopped: false, detail };
|
|
123
|
+
}
|
|
90
124
|
export async function ensureControlPlaneRunning(scriptPath, nodePath = process.execPath, timeoutMs = 15000) {
|
|
91
125
|
if (await isControlPlaneRunning()) {
|
|
92
126
|
return { started: false };
|
package/dist/controlplane/ui.js
CHANGED
|
@@ -547,6 +547,20 @@ export function renderControlPlaneHtml(token = "") {
|
|
|
547
547
|
</table>
|
|
548
548
|
</div>
|
|
549
549
|
</div>
|
|
550
|
+
<div class="card">
|
|
551
|
+
<div class="card-head">
|
|
552
|
+
<div>
|
|
553
|
+
<h2>Live 5250 screens</h2>
|
|
554
|
+
<div class="sub">Green screens your coding assistant is driving. Open one to watch, type and click alongside it.</div>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
<div class="table-wrap">
|
|
558
|
+
<table>
|
|
559
|
+
<thead><tr><th>Connection</th><th>Host</th><th>State</th><th>Last input</th><th></th></tr></thead>
|
|
560
|
+
<tbody id="liveBody"></tbody>
|
|
561
|
+
</table>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
550
564
|
<div class="help" id="versionCheckedAt"></div>
|
|
551
565
|
</div>
|
|
552
566
|
</section>
|
|
@@ -1327,6 +1341,32 @@ export function renderControlPlaneHtml(token = "") {
|
|
|
1327
1341
|
body.appendChild(tr);
|
|
1328
1342
|
}
|
|
1329
1343
|
}
|
|
1344
|
+
function renderLiveViews(views) {
|
|
1345
|
+
const body = $("liveBody");
|
|
1346
|
+
body.innerHTML = "";
|
|
1347
|
+
const rows = [];
|
|
1348
|
+
for (const view of Array.isArray(views) ? views : []) {
|
|
1349
|
+
for (const s of view.sessions || []) rows.push({ view, s });
|
|
1350
|
+
}
|
|
1351
|
+
if (rows.length === 0) {
|
|
1352
|
+
const tr = document.createElement("tr");
|
|
1353
|
+
tr.innerHTML = "<td colspan='5' style='padding:0'><div class='empty compact'><div class='empty-icon'>" + icon("pulse") + "</div><h3>No 5250 session</h3><p>A screen appears here when your coding assistant opens one with ibmi.tn5250.connect.</p></div></td>";
|
|
1354
|
+
body.appendChild(tr);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
for (const { view, s } of rows) {
|
|
1358
|
+
const url = new URL(view.url);
|
|
1359
|
+
url.searchParams.set("s", s.connectionName);
|
|
1360
|
+
const tr = document.createElement("tr");
|
|
1361
|
+
tr.innerHTML = "<td><span class='cell-primary'>" + escapeHtml(s.connectionName) + "</span></td>" +
|
|
1362
|
+
"<td class='mono' style='font-size:12.5px'>" + escapeHtml(s.host || "") + "</td>" +
|
|
1363
|
+
"<td><span class='chip " + (s.connected ? "ok" : "danger") + "'>" + (s.connected ? (s.signedOn ? "Signed on" : "Connected") : "Closed") + "</span></td>" +
|
|
1364
|
+
"<td>" + escapeHtml(s.lastActor === "agent" ? "Agent" : s.lastActor === "person" ? "You" : "-") + "</td>" +
|
|
1365
|
+
"<td style='text-align:right'><a class='btn' href='" + escapeHtml(url.toString()) + "' target='_blank' rel='noopener'>Open</a></td>";
|
|
1366
|
+
body.appendChild(tr);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
async function loadLiveViews() { renderLiveViews((await api("/api/tn5250/live")).views || []); }
|
|
1330
1370
|
const SETTINGS_FIELDS = [
|
|
1331
1371
|
["sessionIdleMinutes", 1, 1440, 30],
|
|
1332
1372
|
["sessionPingSeconds", 5, 300, 15],
|
|
@@ -1673,6 +1713,7 @@ export function renderControlPlaneHtml(token = "") {
|
|
|
1673
1713
|
if (heartbeatId) clearInterval(heartbeatId);
|
|
1674
1714
|
heartbeatId = setInterval(async () => {
|
|
1675
1715
|
const ok = await pingHealth();
|
|
1716
|
+
if (ok && state.online && !state.busy) loadLiveViews().catch(() => {});
|
|
1676
1717
|
if (ok && !state.online) {
|
|
1677
1718
|
setServiceState("online", "Online", "Local control service");
|
|
1678
1719
|
await refreshAll();
|
|
@@ -1689,7 +1730,7 @@ export function renderControlPlaneHtml(token = "") {
|
|
|
1689
1730
|
return;
|
|
1690
1731
|
}
|
|
1691
1732
|
setServiceState("online", "Online", "Local control service");
|
|
1692
|
-
await Promise.allSettled([loadConnections(), loadRuntimeStatus(), loadServiceStatus(), loadVersions(), loadSessions(), loadSettings(), loadClients()]);
|
|
1733
|
+
await Promise.allSettled([loadConnections(), loadRuntimeStatus(), loadServiceStatus(), loadVersions(), loadSessions(), loadLiveViews(), loadSettings(), loadClients()]);
|
|
1693
1734
|
});
|
|
1694
1735
|
}
|
|
1695
1736
|
|
|
@@ -3,6 +3,7 @@ import net from "net";
|
|
|
3
3
|
import tls from "tls";
|
|
4
4
|
import { AID, FLAG_ATN, FLAG_SRQ, OP_NO_OP, buildGdsRecord, parseGdsRecord } from "./datastream.js";
|
|
5
5
|
import { Screen } from "./screen.js";
|
|
6
|
+
import { charsetFor, keyboardTypeFor } from "./ebcdic.js";
|
|
6
7
|
import { DO, DONT, ENV_SEND, OPT_BINARY, OPT_EOR, OPT_NEW_ENVIRON, OPT_TERMINAL_TYPE, TT_SEND, TelnetParser, WILL, WONT, envIs, frameRecord, parseEnvSend, telnetCommand, terminalTypeIs } from "./telnet.js";
|
|
7
8
|
const AID_ALIASES = {
|
|
8
9
|
ENTER: AID.ENTER,
|
|
@@ -18,6 +19,25 @@ const AID_ALIASES = {
|
|
|
18
19
|
CLEAR: AID.CLEAR,
|
|
19
20
|
RECORDBACKSPACE: AID.RECORD_BACKSPACE
|
|
20
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Other names for the two non-AID host keys. In IBM emulators (ACS, PC5250)
|
|
24
|
+
* Esc is Attention and Shift+Esc is System Request.
|
|
25
|
+
*/
|
|
26
|
+
const HOST_KEY_ALIASES = {
|
|
27
|
+
ATTN: "ATTN",
|
|
28
|
+
ATTENTION: "ATTN",
|
|
29
|
+
ESC: "ATTN",
|
|
30
|
+
ESCAPE: "ATTN",
|
|
31
|
+
SYSREQ: "SYSREQ",
|
|
32
|
+
SYSRQ: "SYSREQ",
|
|
33
|
+
SYSTEMREQUEST: "SYSREQ",
|
|
34
|
+
SHIFTESC: "SYSREQ",
|
|
35
|
+
SHIFTESCAPE: "SYSREQ"
|
|
36
|
+
};
|
|
37
|
+
/** "ATTN" or "SYSREQ" for Attention / System Request key names (including Esc and Shift+Esc). */
|
|
38
|
+
export function hostKeyFor(name) {
|
|
39
|
+
return HOST_KEY_ALIASES[name.toUpperCase().replace(/[\s_+-]/g, "")];
|
|
40
|
+
}
|
|
21
41
|
/** AID byte for a key name, or undefined when it is not an AID key. */
|
|
22
42
|
export function aidForKey(name) {
|
|
23
43
|
const key = name.toUpperCase().replace(/[\s_-]/g, "");
|
|
@@ -44,6 +64,8 @@ export class Tn5250Client extends EventEmitter {
|
|
|
44
64
|
willSent = new Set();
|
|
45
65
|
doSent = new Set();
|
|
46
66
|
lastUpdateAt = 0;
|
|
67
|
+
/** Host records received so far; lets callers tell whether the host answered. */
|
|
68
|
+
recordsReceived = 0;
|
|
47
69
|
constructor(options) {
|
|
48
70
|
super();
|
|
49
71
|
this.options = options;
|
|
@@ -216,9 +238,10 @@ export class Tn5250Client extends EventEmitter {
|
|
|
216
238
|
const values = [];
|
|
217
239
|
if (this.options.deviceName)
|
|
218
240
|
values.push({ kind: "uservar", name: "DEVNAME", value: ascii(this.options.deviceName) });
|
|
219
|
-
|
|
220
|
-
values.push({ kind: "uservar", name: "
|
|
221
|
-
values.push({ kind: "uservar", name: "
|
|
241
|
+
const ccsid = this.screen.ccsid;
|
|
242
|
+
values.push({ kind: "uservar", name: "KBDTYPE", value: ascii(keyboardTypeFor(ccsid)) });
|
|
243
|
+
values.push({ kind: "uservar", name: "CODEPAGE", value: ascii(String(ccsid)) });
|
|
244
|
+
values.push({ kind: "uservar", name: "CHARSET", value: ascii(String(charsetFor(ccsid))) });
|
|
222
245
|
const signOn = this.options.autoSignOn;
|
|
223
246
|
if (signOn) {
|
|
224
247
|
// RFC 4777: an empty IBMRSEED means IBMSUBSPW carries the password unencrypted.
|
|
@@ -239,11 +262,12 @@ export class Tn5250Client extends EventEmitter {
|
|
|
239
262
|
this.screen.warnings.push(err?.message || String(err));
|
|
240
263
|
}
|
|
241
264
|
this.lastUpdateAt = Date.now();
|
|
265
|
+
this.recordsReceived += 1;
|
|
242
266
|
this.emit("update");
|
|
243
267
|
}
|
|
244
268
|
/** Send an AID key (Enter, F1-F24, PageUp, ...) or Attn / SysReq. */
|
|
245
269
|
sendKey(name) {
|
|
246
|
-
const key = name.toUpperCase().replace(/[\s_-]/g, "");
|
|
270
|
+
const key = hostKeyFor(name) || name.toUpperCase().replace(/[\s_-]/g, "");
|
|
247
271
|
if (key === "ATTN") {
|
|
248
272
|
this.write(frameRecord(buildGdsRecord(OP_NO_OP, new Uint8Array(0), FLAG_ATN)));
|
|
249
273
|
this.screen.keyboardLocked = true;
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EBCDIC conversion for the 5250 data stream.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Single-byte (SBCS) code pages: CCSID 37 is the base table and the others
|
|
5
|
+
* are stored as differences from it. Each table maps every byte from 0x40 to
|
|
6
|
+
* 0xFE to its Unicode character; bytes below 0x40 are controls, attributes
|
|
7
|
+
* and orders in the 5250 data stream and render as blanks. Double-byte
|
|
8
|
+
* (DBCS) code pages are not supported.
|
|
7
9
|
*/
|
|
8
|
-
|
|
10
|
+
import { CCSID_DELTAS } from "./ebcdicTables.js";
|
|
11
|
+
export const SUPPORTED_CCSIDS = [37, ...Object.keys(CCSID_DELTAS).map(Number)].sort((a, b) => a - b);
|
|
12
|
+
/** Graphic character set (GCSGID) announced with the code page: 695 for the euro code pages, else 697. */
|
|
13
|
+
export function charsetFor(ccsid) {
|
|
14
|
+
return ccsid >= 1140 && ccsid <= 1149 ? 695 : 697;
|
|
15
|
+
}
|
|
16
|
+
/** Keyboard type (KBDTYPE) that matches the code page's country. */
|
|
17
|
+
export function keyboardTypeFor(ccsid) {
|
|
18
|
+
const base = ccsid >= 1140 && ccsid <= 1149 ? [37, 273, 277, 278, 280, 284, 285, 297, 500, 871][ccsid - 1140] : ccsid;
|
|
19
|
+
const map = {
|
|
20
|
+
37: "USB", 273: "AGB", 277: "DMB", 278: "SWB", 280: "ITB", 284: "SPB", 285: "UKB", 297: "FAB", 500: "BLB", 871: "ICB", 1047: "USB"
|
|
21
|
+
};
|
|
22
|
+
return map[base] || "USB";
|
|
23
|
+
}
|
|
24
|
+
export function isSupportedCcsid(ccsid) {
|
|
25
|
+
return SUPPORTED_CCSIDS.includes(ccsid);
|
|
26
|
+
}
|
|
9
27
|
// Unicode code points for EBCDIC 0x40..0xFE, 16 per row.
|
|
10
28
|
const CCSID37_FROM_40 = [
|
|
11
29
|
/* 0x40 */ 0x0020, 0x00A0, 0x00E2, 0x00E4, 0x00E0, 0x00E1, 0x00E3, 0x00E5, 0x00E7, 0x00F1, 0x00A2, 0x002E, 0x003C, 0x0028, 0x002B, 0x007C,
|
|
@@ -23,12 +41,13 @@ const CCSID37_FROM_40 = [
|
|
|
23
41
|
];
|
|
24
42
|
const tables = new Map();
|
|
25
43
|
function buildTable(ccsid) {
|
|
26
|
-
if (ccsid
|
|
44
|
+
if (!isSupportedCcsid(ccsid))
|
|
27
45
|
throw new Error(`CCSID ${ccsid} is not supported for TN5250 (supported: ${SUPPORTED_CCSIDS.join(", ")})`);
|
|
46
|
+
const delta = CCSID_DELTAS[ccsid] || {};
|
|
28
47
|
const toChar = new Array(256).fill(" ");
|
|
29
48
|
const fromChar = new Map();
|
|
30
49
|
for (let i = 0; i < CCSID37_FROM_40.length; i += 1) {
|
|
31
|
-
const ch = String.fromCodePoint(CCSID37_FROM_40[i]);
|
|
50
|
+
const ch = String.fromCodePoint(delta[0x40 + i] ?? CCSID37_FROM_40[i]);
|
|
32
51
|
toChar[0x40 + i] = ch;
|
|
33
52
|
if (!fromChar.has(ch))
|
|
34
53
|
fromChar.set(ch, 0x40 + i);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EBCDIC single-byte code pages for TN5250, as differences from CCSID 37.
|
|
3
|
+
* Generated from the IBM code page tables shipped with the Java runtime
|
|
4
|
+
* (bytes 0x40-0xFE). Do not edit by hand.
|
|
5
|
+
*/
|
|
6
|
+
export const CCSID_DELTAS = {
|
|
7
|
+
273: { 0x43: 0x007b, 0x4a: 0x00c4, 0x4f: 0x0021, 0x59: 0x007e, 0x5a: 0x00dc, 0x5f: 0x005e, 0x63: 0x005b, 0x6a: 0x00f6, 0x7c: 0x00a7, 0xa1: 0x00df, 0xb0: 0x00a2, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00fc, 0xdc: 0x007d, 0xe0: 0x00d6, 0xec: 0x005c, 0xfc: 0x005d },
|
|
8
|
+
277: { 0x47: 0x007d, 0x4a: 0x0023, 0x4f: 0x0021, 0x5a: 0x00a4, 0x5b: 0x00c5, 0x5f: 0x005e, 0x67: 0x0024, 0x6a: 0x00f8, 0x70: 0x00a6, 0x7b: 0x00c6, 0x7c: 0x00d8, 0x80: 0x0040, 0x9c: 0x007b, 0x9e: 0x005b, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e6, 0xd0: 0x00e5, 0xdc: 0x007e },
|
|
9
|
+
278: { 0x43: 0x007b, 0x47: 0x007d, 0x4a: 0x00a7, 0x4f: 0x0021, 0x51: 0x0060, 0x5a: 0x00a4, 0x5b: 0x00c5, 0x5f: 0x005e, 0x63: 0x0023, 0x67: 0x0024, 0x6a: 0x00f6, 0x71: 0x005c, 0x79: 0x00e9, 0x7b: 0x00c4, 0x7c: 0x00d6, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xb5: 0x005b, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00e5, 0xdc: 0x007e, 0xe0: 0x00c9, 0xec: 0x0040 },
|
|
10
|
+
280: { 0x44: 0x007b, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x005d, 0x54: 0x007d, 0x58: 0x007e, 0x5a: 0x00e9, 0x5f: 0x005e, 0x6a: 0x00f2, 0x79: 0x00f9, 0x7b: 0x00a3, 0x7c: 0x00a7, 0x90: 0x005b, 0xa1: 0x00ec, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e0, 0xcd: 0x00a6, 0xd0: 0x00e8, 0xdd: 0x0060, 0xe0: 0x00e7 },
|
|
11
|
+
284: { 0x49: 0x00a6, 0x4a: 0x005b, 0x5a: 0x005d, 0x69: 0x0023, 0x6a: 0x00f1, 0x7b: 0x00d1, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xba: 0x005e, 0xbb: 0x0021, 0xbd: 0x007e },
|
|
12
|
+
285: { 0x4a: 0x0024, 0x5b: 0x00a3, 0xa1: 0x00af, 0xb0: 0x00a2, 0xb1: 0x005b, 0xba: 0x005e, 0xbc: 0x007e },
|
|
13
|
+
297: { 0x44: 0x0040, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x007b, 0x54: 0x007d, 0x5a: 0x00a7, 0x5f: 0x005e, 0x6a: 0x00f9, 0x79: 0x00b5, 0x7b: 0x00a3, 0x7c: 0x00e0, 0x90: 0x005b, 0xa0: 0x0060, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x005d, 0xba: 0x00ac, 0xbb: 0x007c, 0xbd: 0x007e, 0xc0: 0x00e9, 0xd0: 0x00e8, 0xdd: 0x00a6, 0xe0: 0x00e7 },
|
|
14
|
+
500: { 0x4a: 0x005b, 0x4f: 0x0021, 0x5a: 0x005d, 0x5f: 0x005e, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c },
|
|
15
|
+
871: { 0x4a: 0x00de, 0x4f: 0x0021, 0x5a: 0x00c6, 0x5f: 0x00d6, 0x79: 0x00f0, 0x7c: 0x00d0, 0x8c: 0x0060, 0x8e: 0x007b, 0x9c: 0x007d, 0x9e: 0x005d, 0xa1: 0x00f6, 0xac: 0x0040, 0xae: 0x005b, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xbe: 0x005c, 0xc0: 0x00fe, 0xcc: 0x007e, 0xd0: 0x00e6, 0xe0: 0x00b4, 0xec: 0x005e },
|
|
16
|
+
1047: { 0x5f: 0x005e, 0xad: 0x005b, 0xb0: 0x00ac, 0xba: 0x00dd, 0xbb: 0x00a8, 0xbd: 0x005d },
|
|
17
|
+
1140: { 0x9f: 0x20ac },
|
|
18
|
+
1141: { 0x43: 0x007b, 0x4a: 0x00c4, 0x4f: 0x0021, 0x59: 0x007e, 0x5a: 0x00dc, 0x5f: 0x005e, 0x63: 0x005b, 0x6a: 0x00f6, 0x7c: 0x00a7, 0x9f: 0x20ac, 0xa1: 0x00df, 0xb0: 0x00a2, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00fc, 0xdc: 0x007d, 0xe0: 0x00d6, 0xec: 0x005c, 0xfc: 0x005d },
|
|
19
|
+
1142: { 0x47: 0x007d, 0x4a: 0x0023, 0x4f: 0x0021, 0x5a: 0x20ac, 0x5b: 0x00c5, 0x5f: 0x005e, 0x67: 0x0024, 0x6a: 0x00f8, 0x70: 0x00a6, 0x7b: 0x00c6, 0x7c: 0x00d8, 0x80: 0x0040, 0x9c: 0x007b, 0x9e: 0x005b, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e6, 0xd0: 0x00e5, 0xdc: 0x007e },
|
|
20
|
+
1143: { 0x43: 0x007b, 0x47: 0x007d, 0x4a: 0x00a7, 0x4f: 0x0021, 0x51: 0x0060, 0x5a: 0x20ac, 0x5b: 0x00c5, 0x5f: 0x005e, 0x63: 0x0023, 0x67: 0x0024, 0x6a: 0x00f6, 0x71: 0x005c, 0x79: 0x00e9, 0x7b: 0x00c4, 0x7c: 0x00d6, 0x9f: 0x005d, 0xa1: 0x00fc, 0xb0: 0x00a2, 0xb5: 0x005b, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e4, 0xcc: 0x00a6, 0xd0: 0x00e5, 0xdc: 0x007e, 0xe0: 0x00c9, 0xec: 0x0040 },
|
|
21
|
+
1144: { 0x44: 0x007b, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x005d, 0x54: 0x007d, 0x58: 0x007e, 0x5a: 0x00e9, 0x5f: 0x005e, 0x6a: 0x00f2, 0x79: 0x00f9, 0x7b: 0x00a3, 0x7c: 0x00a7, 0x90: 0x005b, 0x9f: 0x20ac, 0xa1: 0x00ec, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x0040, 0xba: 0x00ac, 0xbb: 0x007c, 0xc0: 0x00e0, 0xcd: 0x00a6, 0xd0: 0x00e8, 0xdd: 0x0060, 0xe0: 0x00e7 },
|
|
22
|
+
1145: { 0x49: 0x00a6, 0x4a: 0x005b, 0x5a: 0x005d, 0x69: 0x0023, 0x6a: 0x00f1, 0x7b: 0x00d1, 0x9f: 0x20ac, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xba: 0x005e, 0xbb: 0x0021, 0xbd: 0x007e },
|
|
23
|
+
1146: { 0x4a: 0x0024, 0x5b: 0x00a3, 0x9f: 0x20ac, 0xa1: 0x00af, 0xb0: 0x00a2, 0xb1: 0x005b, 0xba: 0x005e, 0xbc: 0x007e },
|
|
24
|
+
1147: { 0x44: 0x0040, 0x48: 0x005c, 0x4a: 0x00b0, 0x4f: 0x0021, 0x51: 0x007b, 0x54: 0x007d, 0x5a: 0x00a7, 0x5f: 0x005e, 0x6a: 0x00f9, 0x79: 0x00b5, 0x7b: 0x00a3, 0x7c: 0x00e0, 0x90: 0x005b, 0x9f: 0x20ac, 0xa0: 0x0060, 0xa1: 0x00a8, 0xb0: 0x00a2, 0xb1: 0x0023, 0xb5: 0x005d, 0xba: 0x00ac, 0xbb: 0x007c, 0xbd: 0x007e, 0xc0: 0x00e9, 0xd0: 0x00e8, 0xdd: 0x00a6, 0xe0: 0x00e7 },
|
|
25
|
+
1148: { 0x4a: 0x005b, 0x4f: 0x0021, 0x5a: 0x005d, 0x5f: 0x005e, 0x9f: 0x20ac, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c },
|
|
26
|
+
1149: { 0x4a: 0x00de, 0x4f: 0x0021, 0x5a: 0x00c6, 0x5f: 0x00d6, 0x79: 0x00f0, 0x7c: 0x00d0, 0x8c: 0x0060, 0x8e: 0x007b, 0x9c: 0x007d, 0x9e: 0x005d, 0x9f: 0x20ac, 0xa1: 0x00f6, 0xac: 0x0040, 0xae: 0x005b, 0xb0: 0x00a2, 0xba: 0x00ac, 0xbb: 0x007c, 0xbe: 0x005c, 0xc0: 0x00fe, 0xcc: 0x007e, 0xd0: 0x00e6, 0xe0: 0x00b4, 0xec: 0x005e }
|
|
27
|
+
};
|