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.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -0,0 +1,476 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { Tools } from "../../ibmi/Tools.js";
5
+ import { checkLibrary } from "../policy.js";
6
+ import { clampNumber, enforceWritable, guardLocalPath, json, jsonError } from "../tools.js";
7
+ /**
8
+ * Bulk source member download/upload between a library's source files and a
9
+ * local LIB/FILE/MEMBER.ext tree. A manifest in the tree root records each
10
+ * member's version and SHA-256 at download time, so uploads only send members
11
+ * edited locally and refuse to overwrite members that changed on the IBM i
12
+ * since they were downloaded (unless force is true).
13
+ */
14
+ export const MEMBER_MANIFEST_NAME = ".mcp-for-i-members.json";
15
+ const MANIFEST_FORMAT = "mcp-for-i-members";
16
+ const DEFAULT_MAX_MEMBERS = 500;
17
+ const HARD_MAX_MEMBERS = 5000;
18
+ const ANNOTATIONS_DOWNLOAD = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
19
+ const ANNOTATIONS_UPLOAD = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
20
+ export const bulkMembersModule = {
21
+ tools: [
22
+ {
23
+ name: "ibmi.members.downloadAll",
24
+ description: "Download every member of a source file (or of all source files in a library) to a local LIB/FILE/MEMBER.ext tree. " +
25
+ "The extension is the lowercased member type. Writes a manifest (" + MEMBER_MANIFEST_NAME + ") with each member's version " +
26
+ "and SHA-256 so ibmi.members.uploadChanged can detect conflicts. Local files edited since the last download are kept unless overwrite is true.",
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ connectionName: { type: "string" },
31
+ localPath: { type: "string", description: "Local root directory of the LIB/FILE/MEMBER.ext tree." },
32
+ library: { type: "string" },
33
+ sourceFile: { type: "string", description: "One source file. Omit to download all source files in the library." },
34
+ sourceFiles: { type: "array", items: { type: "string" }, description: "Several source files." },
35
+ memberPrefix: { type: "string" },
36
+ sourceTypes: { type: "array", items: { type: "string" } },
37
+ maxMembers: { type: "number", default: DEFAULT_MAX_MEMBERS, description: `Member cap (1-${HARD_MAX_MEMBERS}).` },
38
+ overwrite: { type: "boolean", default: false, description: "Replace local files that were edited since the last download." }
39
+ },
40
+ required: ["localPath", "library"],
41
+ additionalProperties: false
42
+ },
43
+ annotations: ANNOTATIONS_DOWNLOAD
44
+ },
45
+ {
46
+ name: "ibmi.members.uploadChanged",
47
+ description: "Upload members from a local LIB/FILE/MEMBER.ext tree (as written by ibmi.members.downloadAll) that changed locally since download. " +
48
+ "Unchanged files are skipped without contacting the IBM i. A member that changed on the IBM i since download (or has no manifest entry " +
49
+ "and differs) is refused as a conflict unless force is true. Missing members are created with SRCTYPE from the manifest or file extension.",
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ connectionName: { type: "string" },
54
+ localPath: { type: "string", description: "Local root directory of the LIB/FILE/MEMBER.ext tree." },
55
+ library: { type: "string", description: "Only upload members of this library." },
56
+ sourceFile: { type: "string", description: "Only upload members of this source file." },
57
+ members: { type: "array", items: { type: "string" }, description: "Only upload these member names." },
58
+ force: { type: "boolean", default: false, description: "Overwrite members that changed on the IBM i since download." },
59
+ createMissing: { type: "boolean", default: true },
60
+ dryRun: { type: "boolean", default: false },
61
+ maxMembers: { type: "number", default: DEFAULT_MAX_MEMBERS, description: `Cap on members uploaded (1-${HARD_MAX_MEMBERS}).` },
62
+ approve: { type: "boolean" }
63
+ },
64
+ required: ["localPath"],
65
+ additionalProperties: false
66
+ },
67
+ annotations: ANNOTATIONS_UPLOAD
68
+ }
69
+ ],
70
+ async handle(ctx, name, args) {
71
+ switch (name) {
72
+ case "ibmi.members.downloadAll":
73
+ return downloadAll(ctx, args || {});
74
+ case "ibmi.members.uploadChanged":
75
+ return uploadChanged(ctx, args || {});
76
+ default:
77
+ throw new Error(`Unknown tool: ${name}`);
78
+ }
79
+ }
80
+ };
81
+ function qsysName(conn, value, label) {
82
+ const raw = String(value ?? "").trim().toUpperCase();
83
+ if (!raw)
84
+ throw new Error(`${label} is required`);
85
+ if (!conn.validQsysName(raw))
86
+ throw new Error(`Invalid ${label}: ${value}`);
87
+ return raw;
88
+ }
89
+ export function extensionForSourceType(sourceType) {
90
+ const ext = String(sourceType ?? "").trim().toLowerCase();
91
+ return /^[a-z0-9#@$_]{1,10}$/.test(ext) ? ext : "mbr";
92
+ }
93
+ export function sourceTypeForExtension(ext) {
94
+ const type = String(ext || "").trim().toUpperCase();
95
+ if (!type || type === "MBR")
96
+ return undefined;
97
+ return /^[A-Z0-9#@$_]{1,10}$/.test(type) ? type : undefined;
98
+ }
99
+ function normalizeContent(content) {
100
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
101
+ }
102
+ function sha256(content) {
103
+ return crypto.createHash("sha256").update(content, "utf8").digest("hex");
104
+ }
105
+ function memberKey(library, sourceFile, member) {
106
+ return `${library}/${sourceFile}/${member}`;
107
+ }
108
+ function emptyManifest(connectionName) {
109
+ return { format: MANIFEST_FORMAT, version: 1, connectionName, updatedAt: new Date().toISOString(), members: {} };
110
+ }
111
+ async function readManifest(ctx, root) {
112
+ const manifestPath = await guardLocalPath(ctx, path.join(root, MEMBER_MANIFEST_NAME), "read");
113
+ let text;
114
+ try {
115
+ text = await fs.readFile(manifestPath, "utf8");
116
+ }
117
+ catch (err) {
118
+ if (err?.code === "ENOENT")
119
+ return { manifest: emptyManifest(), existed: false };
120
+ throw err;
121
+ }
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(text);
125
+ }
126
+ catch {
127
+ throw new Error(`Member manifest ${manifestPath} is not valid JSON`);
128
+ }
129
+ if (parsed?.format !== MANIFEST_FORMAT || typeof parsed?.members !== "object" || parsed.members === null) {
130
+ throw new Error(`Member manifest ${manifestPath} has an unknown format`);
131
+ }
132
+ return { manifest: parsed, existed: true };
133
+ }
134
+ async function writeManifest(ctx, root, manifest) {
135
+ const manifestPath = await guardLocalPath(ctx, path.join(root, MEMBER_MANIFEST_NAME), "write");
136
+ manifest.updatedAt = new Date().toISOString();
137
+ const sorted = {};
138
+ for (const key of Object.keys(manifest.members).sort())
139
+ sorted[key] = manifest.members[key];
140
+ manifest.members = sorted;
141
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
142
+ return manifestPath;
143
+ }
144
+ async function readLocalText(filePath) {
145
+ try {
146
+ return await fs.readFile(filePath, "utf8");
147
+ }
148
+ catch (err) {
149
+ if (err?.code === "ENOENT")
150
+ return undefined;
151
+ throw err;
152
+ }
153
+ }
154
+ async function listSourceFiles(conn, library) {
155
+ const rows = await conn.runSQL(`select SYSTEM_TABLE_NAME from QSYS2.SYSTABLES where SYSTEM_TABLE_SCHEMA=${Tools.sqlString(library)} ` +
156
+ `and FILE_TYPE='S' order by SYSTEM_TABLE_NAME`);
157
+ return rows
158
+ .map((row) => String(row?.SYSTEM_TABLE_NAME || "").trim().toUpperCase())
159
+ .filter((name) => name && conn.validQsysName(name));
160
+ }
161
+ function entryFromSnapshot(snapshot, sourceType, text, relPath) {
162
+ return {
163
+ library: snapshot.identity.library,
164
+ sourceFile: snapshot.identity.sourceFile,
165
+ member: snapshot.identity.member,
166
+ sourceType,
167
+ ...(text ? { text } : {}),
168
+ path: relPath,
169
+ sha256: snapshot.sha256,
170
+ version: snapshot.version,
171
+ ...(snapshot.metadata?.lastChanged ? { lastChanged: snapshot.metadata.lastChanged } : {}),
172
+ syncedAt: new Date().toISOString()
173
+ };
174
+ }
175
+ async function downloadAll(ctx, args) {
176
+ const conn = await ctx.ensureActive(args.connectionName);
177
+ const library = qsysName(conn, args.library, "library");
178
+ const root = await guardLocalPath(ctx, args.localPath, "write");
179
+ const maxMembers = clampNumber(args.maxMembers, DEFAULT_MAX_MEMBERS, 1, HARD_MAX_MEMBERS);
180
+ const overwrite = Boolean(args.overwrite);
181
+ let sourceFiles;
182
+ if (args.sourceFile)
183
+ sourceFiles = [qsysName(conn, args.sourceFile, "sourceFile")];
184
+ else if (Array.isArray(args.sourceFiles) && args.sourceFiles.length > 0) {
185
+ sourceFiles = Array.from(new Set(args.sourceFiles.map((value) => qsysName(conn, value, "sourceFile"))));
186
+ }
187
+ else
188
+ sourceFiles = await listSourceFiles(conn, library);
189
+ // Collect the member list first so the cap applies across all files.
190
+ const members = [];
191
+ let truncated = false;
192
+ for (const sourceFile of sourceFiles) {
193
+ let cursor;
194
+ do {
195
+ const page = await conn.content.getMemberListPage({
196
+ library,
197
+ sourceFile,
198
+ memberPrefix: args.memberPrefix,
199
+ sourceTypes: args.sourceTypes,
200
+ limit: 500,
201
+ cursor
202
+ });
203
+ for (const item of page.items || []) {
204
+ if (members.length >= maxMembers) {
205
+ truncated = true;
206
+ break;
207
+ }
208
+ members.push({ sourceFile, name: String(item.name).toUpperCase(), sourceType: String(item.extension || ""), text: String(item.text || "") });
209
+ }
210
+ cursor = !truncated && page.hasMore ? page.nextCursor : undefined;
211
+ } while (cursor);
212
+ if (truncated)
213
+ break;
214
+ }
215
+ const { manifest } = await readManifest(ctx, root);
216
+ manifest.connectionName = conn.currentConnectionName || args.connectionName || manifest.connectionName;
217
+ const downloaded = [];
218
+ const unchanged = [];
219
+ const skipped = [];
220
+ const failed = [];
221
+ for (const item of members) {
222
+ const key = memberKey(library, item.sourceFile, item.name);
223
+ const relPath = `${library}/${item.sourceFile}/${item.name}.${extensionForSourceType(item.sourceType)}`;
224
+ try {
225
+ const target = await guardLocalPath(ctx, path.join(root, ...relPath.split("/")), "write");
226
+ const snapshot = await conn.content.getMemberSnapshot(library, item.sourceFile, item.name, true);
227
+ const existing = await readLocalText(target);
228
+ const prior = manifest.members[key];
229
+ if (existing !== undefined) {
230
+ const localSha = sha256(normalizeContent(existing));
231
+ if (localSha === snapshot.sha256) {
232
+ manifest.members[key] = entryFromSnapshot(snapshot, item.sourceType, item.text, relPath);
233
+ unchanged.push(relPath);
234
+ continue;
235
+ }
236
+ const editedLocally = prior ? localSha !== prior.sha256 : true;
237
+ if (editedLocally && !overwrite) {
238
+ skipped.push({ path: relPath, reason: prior ? "local file edited since last download" : "local file exists and is not in the manifest" });
239
+ continue;
240
+ }
241
+ }
242
+ await fs.mkdir(path.dirname(target), { recursive: true });
243
+ await fs.writeFile(target, snapshot.content, "utf8");
244
+ manifest.members[key] = entryFromSnapshot(snapshot, item.sourceType, item.text, relPath);
245
+ downloaded.push(relPath);
246
+ }
247
+ catch (err) {
248
+ failed.push({ member: key, message: err?.message || String(err) });
249
+ }
250
+ }
251
+ const manifestPath = await writeManifest(ctx, root, manifest);
252
+ const payload = {
253
+ ok: failed.length === 0,
254
+ localRoot: root,
255
+ library,
256
+ sourceFiles,
257
+ manifestPath,
258
+ truncated,
259
+ ...(truncated ? { hint: `Stopped at maxMembers=${maxMembers}; narrow with sourceFile/memberPrefix or raise maxMembers.` } : {}),
260
+ summary: {
261
+ members: members.length,
262
+ downloaded: downloaded.length,
263
+ unchanged: unchanged.length,
264
+ skipped: skipped.length,
265
+ failed: failed.length
266
+ },
267
+ downloaded,
268
+ unchanged,
269
+ skipped,
270
+ failed
271
+ };
272
+ if (failed.length > 0)
273
+ return jsonError(`members.downloadAll finished with ${failed.length} failed member(s)`, payload);
274
+ return json(payload);
275
+ }
276
+ async function walkMemberTree(conn, root, skipped) {
277
+ const out = [];
278
+ const dirents = async (dir) => (await fs.readdir(dir, { withFileTypes: true })).filter(entry => !entry.name.startsWith("."));
279
+ for (const libEntry of await dirents(root)) {
280
+ if (!libEntry.isDirectory())
281
+ continue;
282
+ const library = libEntry.name.toUpperCase();
283
+ if (!conn.validQsysName(library)) {
284
+ skipped.push({ path: libEntry.name, reason: "directory name is not a valid library name" });
285
+ continue;
286
+ }
287
+ for (const fileEntry of await dirents(path.join(root, libEntry.name))) {
288
+ if (!fileEntry.isDirectory())
289
+ continue;
290
+ const sourceFile = fileEntry.name.toUpperCase();
291
+ const fileDir = `${libEntry.name}/${fileEntry.name}`;
292
+ if (!conn.validQsysName(sourceFile)) {
293
+ skipped.push({ path: fileDir, reason: "directory name is not a valid source file name" });
294
+ continue;
295
+ }
296
+ for (const memberEntry of await dirents(path.join(root, libEntry.name, fileEntry.name))) {
297
+ if (!memberEntry.isFile())
298
+ continue;
299
+ const relPath = `${fileDir}/${memberEntry.name}`;
300
+ const dot = memberEntry.name.lastIndexOf(".");
301
+ const member = (dot > 0 ? memberEntry.name.substring(0, dot) : memberEntry.name).toUpperCase();
302
+ const ext = dot > 0 ? memberEntry.name.substring(dot + 1) : "";
303
+ if (!conn.validQsysName(member)) {
304
+ skipped.push({ path: relPath, reason: "file name is not a valid member name" });
305
+ continue;
306
+ }
307
+ out.push({ library, sourceFile, member, ext, relPath, fullPath: path.join(root, libEntry.name, fileEntry.name, memberEntry.name) });
308
+ }
309
+ }
310
+ }
311
+ return out.sort((a, b) => a.relPath.localeCompare(b.relPath));
312
+ }
313
+ async function uploadChanged(ctx, args) {
314
+ const conn = await ctx.ensureActive(args.connectionName);
315
+ const root = await guardLocalPath(ctx, args.localPath, "read");
316
+ const dryRun = Boolean(args.dryRun);
317
+ const force = Boolean(args.force);
318
+ const createMissing = args.createMissing !== false;
319
+ const maxMembers = clampNumber(args.maxMembers, DEFAULT_MAX_MEMBERS, 1, HARD_MAX_MEMBERS);
320
+ const libraryFilter = args.library ? qsysName(conn, args.library, "library") : undefined;
321
+ const fileFilter = args.sourceFile ? qsysName(conn, args.sourceFile, "sourceFile") : undefined;
322
+ const memberFilter = Array.isArray(args.members) && args.members.length > 0
323
+ ? new Set(args.members.map((value) => qsysName(conn, value, "member")))
324
+ : undefined;
325
+ if (!dryRun)
326
+ await enforceWritable(ctx, conn, "qsys.write", args);
327
+ const { manifest, existed: manifestExisted } = await readManifest(ctx, root);
328
+ const skipped = [];
329
+ const candidates = (await walkMemberTree(conn, root, skipped)).filter(item => (!libraryFilter || item.library === libraryFilter) &&
330
+ (!fileFilter || item.sourceFile === fileFilter) &&
331
+ (!memberFilter || memberFilter.has(item.member)));
332
+ const policy = ctx.getPolicy(args.connectionName);
333
+ const deniedLibraries = new Map();
334
+ for (const library of new Set(candidates.map(item => item.library))) {
335
+ const reason = checkLibrary(policy, library);
336
+ if (reason)
337
+ deniedLibraries.set(library, reason);
338
+ }
339
+ const unchanged = [];
340
+ const inSync = [];
341
+ const conflicts = [];
342
+ const planned = [];
343
+ const uploaded = [];
344
+ const failed = [];
345
+ let truncated = false;
346
+ for (const item of candidates) {
347
+ const key = memberKey(item.library, item.sourceFile, item.member);
348
+ const denied = deniedLibraries.get(item.library);
349
+ if (denied) {
350
+ skipped.push({ path: item.relPath, reason: `blocked by policy: ${denied}` });
351
+ continue;
352
+ }
353
+ try {
354
+ const localFile = await guardLocalPath(ctx, item.fullPath, "read");
355
+ const content = normalizeContent(await fs.readFile(localFile, "utf8"));
356
+ const localSha = sha256(content);
357
+ const entry = manifest.members[key];
358
+ if (entry && entry.sha256 === localSha) {
359
+ unchanged.push(item.relPath);
360
+ continue;
361
+ }
362
+ const metadata = await conn.content.getMemberMetadata(item.library, item.sourceFile, item.member);
363
+ let action;
364
+ const sourceType = entry?.sourceType || sourceTypeForExtension(item.ext);
365
+ if (!metadata) {
366
+ if (!createMissing) {
367
+ skipped.push({ path: item.relPath, reason: "member does not exist on the IBM i (createMissing is false)" });
368
+ continue;
369
+ }
370
+ action = "create";
371
+ }
372
+ else {
373
+ action = "update";
374
+ // Same last-change timestamp as at download means the remote member
375
+ // is unchanged; otherwise compare content hashes.
376
+ const remoteUnchanged = Boolean(entry?.lastChanged && metadata.lastChanged && entry.lastChanged === metadata.lastChanged);
377
+ if (!remoteUnchanged) {
378
+ const snapshot = await conn.content.getMemberSnapshot(item.library, item.sourceFile, item.member, true);
379
+ if (snapshot.sha256 === localSha) {
380
+ manifest.members[key] = entryFromSnapshot(snapshot, metadata.extension || sourceType || "", metadata.text, item.relPath);
381
+ inSync.push(item.relPath);
382
+ continue;
383
+ }
384
+ const remoteChanged = entry ? snapshot.sha256 !== entry.sha256 : true;
385
+ if (remoteChanged && !force) {
386
+ conflicts.push({
387
+ path: item.relPath,
388
+ reason: entry ? "member changed on the IBM i since download" : "member exists on the IBM i but is not in the manifest",
389
+ ...(entry ? { expectedVersion: entry.version } : {}),
390
+ actualVersion: snapshot.version
391
+ });
392
+ continue;
393
+ }
394
+ }
395
+ }
396
+ if (planned.length >= maxMembers) {
397
+ truncated = true;
398
+ break;
399
+ }
400
+ planned.push({ path: item.relPath, action, ...(action === "create" ? { sourceType: sourceType || "*NONE" } : {}) });
401
+ if (dryRun)
402
+ continue;
403
+ if (action === "create") {
404
+ await conn.content.createMember(item.library, item.sourceFile, item.member, sourceType);
405
+ }
406
+ try {
407
+ await conn.content.uploadMemberContent(item.library, item.sourceFile, item.member, content);
408
+ }
409
+ finally {
410
+ conn.content.invalidateMemberSnapshot?.(item.library, item.sourceFile, item.member);
411
+ }
412
+ let version;
413
+ try {
414
+ const after = await conn.content.getMemberSnapshot(item.library, item.sourceFile, item.member, true);
415
+ manifest.members[key] = entryFromSnapshot(after, metadata?.extension || sourceType || "", metadata?.text || entry?.text, item.relPath);
416
+ version = after.version;
417
+ }
418
+ catch {
419
+ // The upload worked; record the local hash so the next run treats it as unchanged.
420
+ manifest.members[key] = {
421
+ library: item.library,
422
+ sourceFile: item.sourceFile,
423
+ member: item.member,
424
+ sourceType: metadata?.extension || sourceType || "",
425
+ path: item.relPath,
426
+ sha256: localSha,
427
+ version: `unknown:${localSha.slice(0, 16)}`,
428
+ syncedAt: new Date().toISOString()
429
+ };
430
+ }
431
+ uploaded.push({ path: item.relPath, action, ...(version ? { version } : {}) });
432
+ }
433
+ catch (err) {
434
+ failed.push({ path: item.relPath, message: err?.message || String(err) });
435
+ }
436
+ }
437
+ let manifestPath;
438
+ if (!dryRun && (uploaded.length > 0 || inSync.length > 0)) {
439
+ manifest.connectionName = conn.currentConnectionName || args.connectionName || manifest.connectionName;
440
+ manifestPath = await writeManifest(ctx, root, manifest);
441
+ }
442
+ const payload = {
443
+ ok: failed.length === 0 && conflicts.length === 0,
444
+ localRoot: root,
445
+ dryRun,
446
+ force,
447
+ manifestFound: manifestExisted,
448
+ ...(manifestPath ? { manifestPath } : {}),
449
+ truncated,
450
+ summary: {
451
+ candidates: candidates.length,
452
+ planned: planned.length,
453
+ uploaded: uploaded.length,
454
+ unchanged: unchanged.length,
455
+ alreadyInSync: inSync.length,
456
+ conflicts: conflicts.length,
457
+ skipped: skipped.length,
458
+ failed: failed.length
459
+ },
460
+ planned,
461
+ uploaded,
462
+ conflicts,
463
+ skipped,
464
+ failed,
465
+ unchanged,
466
+ alreadyInSync: inSync
467
+ };
468
+ if (failed.length > 0 || conflicts.length > 0) {
469
+ const parts = [
470
+ failed.length > 0 ? `${failed.length} failed` : "",
471
+ conflicts.length > 0 ? `${conflicts.length} conflict(s); re-download or pass force:true to overwrite` : ""
472
+ ].filter(Boolean);
473
+ return jsonError(`members.uploadChanged: ${parts.join(", ")}`, payload);
474
+ }
475
+ return json(payload);
476
+ }
@@ -0,0 +1,189 @@
1
+ import { makeAliasName } from "../../ibmi/sourceDates.js";
2
+ import { clampInteger, json, requireQsysName } from "../tools.js";
3
+ const DEFAULT_MAX_LINES = 500;
4
+ const HARD_MAX_LINES = 5000;
5
+ const DEFAULT_MAX_MEMBERS = 50;
6
+ const HARD_MAX_MEMBERS = 500;
7
+ /**
8
+ * SRCDAT holds a two-digit year (YYMMDD). IBM i uses the 1940-2039 window
9
+ * for two-digit years: 40-99 are 19xx and 00-39 are 20xx. A zero or blank
10
+ * SRCDAT means the line has no date.
11
+ */
12
+ export function srcdatToFullDate(value) {
13
+ const digits = Number(value);
14
+ if (!Number.isFinite(digits) || digits <= 0)
15
+ return 0;
16
+ return digits >= 400000 ? 19000000 + digits : 20000000 + digits;
17
+ }
18
+ export function fullDateToIso(value) {
19
+ if (!value)
20
+ return null;
21
+ const text = String(value).padStart(8, "0");
22
+ return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
23
+ }
24
+ /** Accepts YYYY-MM-DD, YYYYMMDD or a six-digit SRCDAT-style YYMMDD. Returns YYYYMMDD as a number. */
25
+ export function parseDateArgument(value, label) {
26
+ const raw = String(value ?? "").trim();
27
+ let year;
28
+ let month;
29
+ let day;
30
+ let match;
31
+ if ((match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw)) || (match = /^(\d{4})(\d{2})(\d{2})$/.exec(raw))) {
32
+ [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
33
+ }
34
+ else if ((match = /^(\d{2})(\d{2})(\d{2})$/.exec(raw))) {
35
+ const full = srcdatToFullDate(raw);
36
+ [year, month, day] = [Math.floor(full / 10000), Number(match[2]), Number(match[3])];
37
+ }
38
+ else {
39
+ throw new Error(`Invalid ${label}: ${raw || "(empty)"}. Use YYYY-MM-DD.`);
40
+ }
41
+ const date = new Date(Date.UTC(year, month - 1, day));
42
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
43
+ throw new Error(`Invalid ${label}: ${raw} is not a real date`);
44
+ }
45
+ return year * 10000 + month * 100 + day;
46
+ }
47
+ function todayFullDate(now = new Date()) {
48
+ return now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate();
49
+ }
50
+ /** A member name, or a generic name using `*` (for example ORD* or *ALL). */
51
+ function memberPattern(value) {
52
+ const raw = String(value ?? "").trim().toUpperCase();
53
+ if (!raw || raw === "*ALL" || raw === "*")
54
+ return { generic: true, like: "%" };
55
+ if (!raw.includes("*"))
56
+ return { generic: false, name: raw };
57
+ if (!/^[A-Z0-9#@$._*]{1,10}$/.test(raw))
58
+ throw new Error(`Invalid member filter: ${value}`);
59
+ const like = raw.replace(/_/g, "\\_").replace(/\*/g, "%");
60
+ return { generic: true, like };
61
+ }
62
+ function quoteSql(value) {
63
+ return `'${value.replace(/'/g, "''")}'`;
64
+ }
65
+ /** Source member date expression, matching how source dates are read elsewhere (blank SRCDAT is 0). */
66
+ const SRCDAT_EXPRESSION = "case when locate('40', hex(M.SRCDAT)) > 0 then 0 when M.SRCDAT = 0 then 0 " +
67
+ "when M.SRCDAT >= 400000 then 19000000 + M.SRCDAT else 20000000 + M.SRCDAT end";
68
+ export function buildChangedLinesSql(library, sourceFile, member, from, to, limit) {
69
+ const alias = `QTEMP."${makeAliasName(`${library}/${sourceFile}/${member}`)}"`;
70
+ return (`create or replace alias ${alias} for "${library}"."${sourceFile}"("${member}");\n` +
71
+ "select X.LINE_NUMBER, X.SRCSEQ, X.FULL_DATE, X.SRCDTA from (" +
72
+ `select row_number() over (order by rrn(M)) as LINE_NUMBER, M.SRCSEQ, ${SRCDAT_EXPRESSION} as FULL_DATE, M.SRCDTA ` +
73
+ `from ${alias} as M) as X where X.FULL_DATE between ${from} and ${to} ` +
74
+ `order by X.LINE_NUMBER fetch first ${limit} rows only`);
75
+ }
76
+ async function changedLines(ctx, args) {
77
+ const conn = await ctx.ensureActive(args?.connectionName);
78
+ const library = requireQsysName(conn, args?.library, "library");
79
+ const sourceFile = requireQsysName(conn, args?.sourceFile, "sourceFile");
80
+ const pattern = memberPattern(args?.member);
81
+ const from = parseDateArgument(args?.from, "from");
82
+ const to = args?.to === undefined || args?.to === null || args?.to === "" ? todayFullDate() : parseDateArgument(args.to, "to");
83
+ if (to < from)
84
+ throw new Error("to must not be before from");
85
+ const maxLines = clampInteger(args?.maxLines, 1, HARD_MAX_LINES, DEFAULT_MAX_LINES);
86
+ const maxMembers = clampInteger(args?.maxMembers, 1, HARD_MAX_MEMBERS, DEFAULT_MAX_MEMBERS);
87
+ let members;
88
+ let membersTruncated = false;
89
+ if (pattern.generic) {
90
+ const memberType = args?.memberType ? String(args.memberType).trim().toUpperCase() : "";
91
+ if (memberType && !/^[A-Z0-9]{1,10}$/.test(memberType))
92
+ throw new Error(`Invalid memberType: ${args.memberType}`);
93
+ const rows = await conn.runSQL("select SYSTEM_TABLE_MEMBER from QSYS2.SYSPARTITIONSTAT " +
94
+ `where SYSTEM_TABLE_SCHEMA = ${quoteSql(library)} and SYSTEM_TABLE_NAME = ${quoteSql(sourceFile)} ` +
95
+ (pattern.like !== "%" ? `and SYSTEM_TABLE_MEMBER like ${quoteSql(pattern.like)} escape '\\' ` : "") +
96
+ (memberType ? `and SOURCE_TYPE = ${quoteSql(memberType)} ` : "") +
97
+ `order by SYSTEM_TABLE_MEMBER fetch first ${maxMembers + 1} rows only`);
98
+ members = rows.map((row) => String(row.SYSTEM_TABLE_MEMBER ?? "").trim()).filter(Boolean);
99
+ membersTruncated = members.length > maxMembers;
100
+ members = members.slice(0, maxMembers).filter(name => conn.validQsysName(name));
101
+ }
102
+ else {
103
+ members = [requireQsysName(conn, pattern.name, "member")];
104
+ }
105
+ const hits = [];
106
+ const errors = [];
107
+ let linesTruncated = false;
108
+ let membersSearched = 0;
109
+ for (const member of members) {
110
+ const remaining = maxLines - hits.length;
111
+ if (remaining <= 0) {
112
+ linesTruncated = true;
113
+ break;
114
+ }
115
+ let rows;
116
+ try {
117
+ rows = await conn.runSQL(buildChangedLinesSql(library, sourceFile, member, from, to, remaining + 1));
118
+ }
119
+ catch (error) {
120
+ if (!pattern.generic)
121
+ throw error;
122
+ errors.push({ member, error: error?.message || String(error) });
123
+ continue;
124
+ }
125
+ membersSearched += 1;
126
+ if (rows.length > remaining)
127
+ linesTruncated = true;
128
+ for (const row of rows.slice(0, remaining)) {
129
+ const sequence = Number(row.SRCSEQ);
130
+ hits.push({
131
+ member,
132
+ line: Number(row.LINE_NUMBER),
133
+ sequence: Number.isFinite(sequence) ? sequence : null,
134
+ date: fullDateToIso(Number(row.FULL_DATE)),
135
+ text: String(row.SRCDTA ?? "").trimEnd()
136
+ });
137
+ }
138
+ }
139
+ const truncated = linesTruncated || membersTruncated;
140
+ const reasons = [
141
+ ...(linesTruncated ? [`Stopped at maxLines=${maxLines}.`] : []),
142
+ ...(membersTruncated ? [`More than maxMembers=${maxMembers} members match; only the first ${maxMembers} were searched.`] : [])
143
+ ];
144
+ return json({
145
+ library,
146
+ sourceFile,
147
+ member: pattern.generic ? String(args?.member || "*ALL").toUpperCase() : members[0],
148
+ from: fullDateToIso(from),
149
+ to: fullDateToIso(to),
150
+ centuryRule: "SRCDAT years 40-99 are 1940-1999 and 00-39 are 2000-2039; lines with no date are never returned.",
151
+ membersSearched,
152
+ hits,
153
+ returned: hits.length,
154
+ truncated,
155
+ ...(truncated ? { truncatedReason: reasons.join(" ") } : {}),
156
+ ...(errors.length ? { errors } : {})
157
+ });
158
+ }
159
+ export const changedLinesModule = {
160
+ tools: [
161
+ {
162
+ name: "ibmi.search.changedLines",
163
+ description: "Find source lines whose change date (SRCDAT) falls in a date range, in one member or in the members of a source file " +
164
+ "matching a generic name (ORD*, *ALL). Returns member, line number, sequence, date and text. Read-only; results are capped.",
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: {
168
+ connectionName: { type: "string", description: "Saved IBM i connection name." },
169
+ library: { type: "string" },
170
+ sourceFile: { type: "string" },
171
+ member: { type: "string", description: "Member name, or a generic name with * (for example ORD*). Default *ALL." },
172
+ memberType: { type: "string", description: "Only search members of this source type (generic member only)." },
173
+ from: { type: "string", description: "Start date, inclusive (YYYY-MM-DD)." },
174
+ to: { type: "string", description: "End date, inclusive (YYYY-MM-DD). Default today." },
175
+ maxLines: { type: "number", description: `Maximum lines returned (default ${DEFAULT_MAX_LINES}, max ${HARD_MAX_LINES}).` },
176
+ maxMembers: { type: "number", description: `Maximum members searched (default ${DEFAULT_MAX_MEMBERS}, max ${HARD_MAX_MEMBERS}).` }
177
+ },
178
+ required: ["library", "sourceFile", "from"],
179
+ additionalProperties: false
180
+ },
181
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
182
+ }
183
+ ],
184
+ handle: async (ctx, name, args) => {
185
+ if (name === "ibmi.search.changedLines")
186
+ return changedLines(ctx, args);
187
+ throw new Error(`Unknown tool: ${name}`);
188
+ }
189
+ };