@jacebenson/jsn 4.3.12 → 4.3.13-dev.101.1

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 (77) hide show
  1. package/README.md +95 -0
  2. package/package.json +2 -1
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +24 -4
  9. package/src/cli.js +41 -9
  10. package/src/command-audience.js +35 -0
  11. package/src/commands/_generic.js +58 -37
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +38 -31
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/changes.js +2 -4
  18. package/src/commands/columns.js +2 -1
  19. package/src/commands/customizations.js +13 -7
  20. package/src/commands/discover.js +54 -0
  21. package/src/commands/docs/db.js +9 -1
  22. package/src/commands/docs/docs.js +25 -2
  23. package/src/commands/docs/ingest.js +20 -3
  24. package/src/commands/docs/install.js +86 -0
  25. package/src/commands/docs/locks.js +34 -0
  26. package/src/commands/docs/refresh.js +22 -2
  27. package/src/commands/docs/replace.js +30 -0
  28. package/src/commands/docs/snapshot-check.js +11 -0
  29. package/src/commands/docs/snapshot.js +130 -0
  30. package/src/commands/docs/sync.js +16 -6
  31. package/src/commands/forms.js +22 -97
  32. package/src/commands/groups.js +11 -8
  33. package/src/commands/history.js +57 -0
  34. package/src/commands/incidents.js +2 -4
  35. package/src/commands/midservers.js +117 -10
  36. package/src/commands/records.js +64 -119
  37. package/src/commands/requests.js +10 -16
  38. package/src/commands/rest.js +15 -2
  39. package/src/commands/scan.js +234 -0
  40. package/src/commands/scopes.js +1 -1
  41. package/src/commands/setup.js +46 -9
  42. package/src/commands/skill.js +72 -76
  43. package/src/commands/tasks.js +2 -4
  44. package/src/commands/tickets.js +31 -22
  45. package/src/commands/uipolicies.js +2 -27
  46. package/src/commands/updatesets.js +66 -13
  47. package/src/commands/users.js +11 -8
  48. package/src/commands/version.js +14 -8
  49. package/src/constrained-query.js +20 -0
  50. package/src/context.js +6 -6
  51. package/src/customization-guidance.js +31 -0
  52. package/src/customizations-report.js +6 -0
  53. package/src/customizations.js +28 -13
  54. package/src/errors.js +23 -1
  55. package/src/essential-records.js +40 -0
  56. package/src/field-behavior.js +15 -0
  57. package/src/help.js +11 -2
  58. package/src/helpers.js +44 -0
  59. package/src/metadata/records.js +249 -0
  60. package/src/midserver-linux-remote.js +210 -0
  61. package/src/midserver-remote-recipe.js +49 -0
  62. package/src/midserver-remote.js +221 -0
  63. package/src/midserver-windows-remote.js +318 -0
  64. package/src/midserver-zip.js +2 -2
  65. package/src/mutation-context.js +74 -0
  66. package/src/mutation-history.js +75 -0
  67. package/src/mutations.js +3 -2
  68. package/src/output.js +34 -2
  69. package/src/record-command-profiles.js +70 -0
  70. package/src/record-profiles.js +93 -0
  71. package/src/records-engine.js +57 -0
  72. package/src/related-lists.js +153 -0
  73. package/src/resolve-record.js +50 -28
  74. package/src/scan.js +249 -0
  75. package/src/sdk-batch.js +130 -0
  76. package/src/sdk.js +157 -18
  77. package/src/update-set-xml.js +89 -0
@@ -1,105 +1,29 @@
1
1
  import { getStringField, interactiveList } from '../helpers.js';
2
2
 
3
- function getDisplayValue(record, key) {
4
- if (!record || typeof record !== 'object') return '';
5
- const val = record[key];
6
- if (val == null) return '';
7
- if (typeof val === 'string') return val;
8
- if (typeof val === 'object') {
9
- if (val.display_value != null && val.display_value !== '') return String(val.display_value);
10
- if (val.value != null) return String(val.value);
11
- }
12
- return String(val);
13
- }
14
-
15
- function getIntValue(record, key) {
16
- if (!record || typeof record !== 'object') return 0;
17
- const val = record[key];
18
- if (val == null) return 0;
19
- if (typeof val === 'number') return Math.floor(val);
20
- if (typeof val === 'string') { const n = parseInt(val, 10); return isNaN(n) ? 0 : n; }
21
- if (typeof val === 'object' && val.value != null) { const n = parseInt(val.value, 10); return isNaN(n) ? 0 : n; }
22
- return 0;
23
- }
24
-
25
- async function showForm(app, table, viewName) {
26
- // 1) Look up the view
27
- const viewParams = new URLSearchParams();
28
- viewParams.set('sysparm_limit', '1');
29
- viewParams.set('sysparm_fields', 'sys_id');
30
- viewParams.set('sysparm_display_value', 'all');
31
- viewParams.set('sysparm_query', `name=${viewName}`);
32
- let viewSysID = '';
33
- try { const vr = await app.sdk.list('sys_ui_view', viewParams); if (vr.length > 0) viewSysID = getStringField(vr[0], 'sys_id'); } catch { /* ignore */ }
34
- if (!viewSysID) {
35
- viewParams.set('sysparm_query', `title=${viewName}`);
36
- try { const vr = await app.sdk.list('sys_ui_view', viewParams); if (vr.length > 0) viewSysID = getStringField(vr[0], 'sys_id'); } catch { /* ignore */ }
37
- }
38
-
39
- // 2) Get sections for this table + view
40
- const secParams = new URLSearchParams();
41
- secParams.set('sysparm_limit', '200');
42
- secParams.set('sysparm_fields', 'sys_id,caption');
43
- secParams.set('sysparm_display_value', 'all');
44
- secParams.set('sysparm_query', `name=${table}^view=${viewName}^ORDERBYposition`);
45
- if (viewSysID) {
46
- secParams.set('sysparm_query', `name=${table}^view=${viewSysID}^ORDERBYposition`);
47
- }
48
- const sections = await app.sdk.list('sys_ui_section', secParams);
49
-
50
- // 3) Get elements for each section
51
- const sectionsOut = [];
52
- for (const sec of sections) {
53
- const secSysID = getStringField(sec, 'sys_id');
54
- const sectionCaption = getDisplayValue(sec, 'caption') || '(unnamed)';
3
+ import { createRecordMetadataReader } from '../metadata/records.js';
55
4
 
56
- let elements = [];
57
- if (secSysID) {
58
- const elemParams = new URLSearchParams();
59
- elemParams.set('sysparm_limit', '500');
60
- elemParams.set('sysparm_fields', 'element,type,label,position');
61
- elemParams.set('sysparm_display_value', 'all');
62
- elemParams.set('sysparm_query', `sys_ui_section=${secSysID}^ORDERBYposition`);
63
- try { elements = await app.sdk.list('sys_ui_element', elemParams); } catch { /* ignore */ }
64
- }
65
-
66
- sectionsOut.push({
67
- caption: sectionCaption,
68
- position: getIntValue(sec, 'position'),
69
- elements: elements
70
- .map(e => ({
71
- type: getDisplayValue(e, 'type'),
72
- label: getDisplayValue(e, 'label'),
73
- element: getDisplayValue(e, 'element'),
74
- position: getIntValue(e, 'position'),
75
- }))
76
- .sort((a, b) => a.position - b.position),
77
- });
5
+ export function formatFormMetadata(metadata) {
6
+ const lines = [`Form: ${metadata.table} → ${metadata.requested_view}`];
7
+ if (metadata.form?.inherited) lines.push(` Inherited layout: ${metadata.form.table}`);
8
+ if (!metadata.form) lines.push(' No unambiguous shared form layout is visible.');
9
+ for (const section of metadata.form?.sections || []) {
10
+ lines.push('', `── ${section.caption || '(unnamed)'} ──`);
11
+ for (const element of section.elements) lines.push(` ${element.label || element.element || '(unnamed)'}${element.type ? ` (${element.type})` : ''}`);
12
+ if (!section.elements.length) lines.push(' (no visible elements)');
78
13
  }
79
-
80
- sectionsOut.sort((a, b) => a.position - b.position);
81
-
82
- // Build formatted output
83
- const lines = [];
84
- lines.push(`Form: ${table} → ${viewName}`);
85
- if (viewSysID) lines.push(` View: ${app.getEffectiveInstance()}/nav_to.do?uri=sys_ui_view.do?sys_id=${viewSysID}`);
86
- lines.push('');
87
-
88
- for (const sec of sectionsOut) {
89
- const secLabel = sec.caption || '(unnamed)';
90
- lines.push(`── ${secLabel} ──`);
91
- if (sec.elements.length === 0) {
92
- lines.push(' (no elements)');
93
- }
94
- for (const el of sec.elements) {
95
- const elLabel = el.label || el.element || '(unnamed)';
96
- const typeStr = el.type ? ` (${el.type})` : '';
97
- lines.push(` ${elLabel}${typeStr}`);
98
- }
99
- lines.push('');
14
+ const configured = metadata.related_lists.filter(item => item.configured);
15
+ if (configured.length) {
16
+ lines.push('', 'Configured related lists:');
17
+ for (const list of configured) lines.push(` ${list.label || list.id} (${list.query_status})`);
100
18
  }
19
+ lines.push('', metadata.coverage.scope);
20
+ for (const warning of metadata.coverage.warnings) lines.push(` ${warning.source}: ${warning.reason}`);
21
+ return lines.join('\n');
22
+ }
101
23
 
102
- process.stdout.write(lines.join('\n') + '\n');
24
+ async function showForm(app, table, view) {
25
+ const metadata = await createRecordMetadataReader(app.sdk).read({ table, view });
26
+ app.ok({ ...metadata, _formatted: formatFormMetadata(metadata) }, { summary: `Form metadata for ${table}` });
103
27
  }
104
28
 
105
29
  export function formsCmd(wrap) {
@@ -164,7 +88,8 @@ export function formsCmd(wrap) {
164
88
  aliases: ['get'],
165
89
  describe: 'Show form layout',
166
90
  builder: (y) => y
167
- .option('view', { type: 'string', default: 'Default view', describe: 'View name' }),
91
+ .option('view', { type: 'string', default: 'Default view', describe: 'Shared view name, title or sys_id' })
92
+ .epilogue('Discovers shared layouts, inherited forms and related-list metadata. JSON includes coverage limits; custom relationship scripts are never executed. Generic records get presentation is unchanged.'),
168
93
  handler: wrap(async (argv, app) => {
169
94
  app.requireInstance();
170
95
  await showForm(app, argv.table, argv.view);
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch, buildRecordPayload } from '../helpers.js';
2
2
  import { declareCapabilities } from '../capabilities.js';
3
3
 
4
4
  declareCapabilities('groups', { mutationSubcommands: ['create', 'update', 'delete'] });
@@ -94,12 +94,12 @@ export function groupsCmd(wrap) {
94
94
  builder: (y) => y
95
95
  .option('name', { alias: 'n', type: 'string', describe: 'Group name' })
96
96
  .option('manager', { type: 'string', describe: 'Manager name or sys_id' })
97
- .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
97
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' })
98
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
99
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
100
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
98
101
  handler: wrap(async (argv, app) => {
99
- const recordData = {};
100
- if (argv.data) {
101
- Object.assign(recordData, JSON.parse(argv.data));
102
- }
102
+ const recordData = buildRecordPayload(argv, { required: false });
103
103
  if (argv.name) recordData.name = argv.name;
104
104
  if (argv.manager) recordData.manager = argv.manager;
105
105
  if (!recordData.name) {
@@ -118,11 +118,14 @@ export function groupsCmd(wrap) {
118
118
  command: 'update <name>',
119
119
  describe: 'Update a group by name or sys_id',
120
120
  builder: (y) => y
121
- .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
121
+ .option('data', { type: 'string', describe: 'JSON data to update' })
122
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
123
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
124
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
122
125
  handler: wrap(async (argv, app) => {
123
126
  const id = argv.name;
124
127
  assertSafeExactMatch(id);
125
- const recordData = JSON.parse(argv.data);
128
+ const recordData = buildRecordPayload(argv);
126
129
  const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
127
130
  const params = new URLSearchParams();
128
131
  params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
@@ -0,0 +1,57 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+ import { listHistory, pruneHistory } from '../mutation-history.js';
3
+
4
+ export function historyCmd(wrap) {
5
+ declareCapabilities('history', { noInstance: true, skipDailyChecks: true });
6
+ const list = wrap(async (argv, app) => {
7
+ const entries = listHistory({ ...argv, instance: argv['instance-url'], profile: argv['profile-name'] });
8
+ app.ok({ entries, retentionDays: 7, _formatted: renderHistory(entries, { details: argv.details }) });
9
+ });
10
+ const filters = b => b
11
+ .option('details', { type: 'boolean', default: false, describe: 'Show full record IDs, instance URLs and reasons' })
12
+ .option('since', { type: 'string', describe: 'Earliest ISO timestamp' })
13
+ .option('until', { type: 'string', describe: 'Latest ISO timestamp' })
14
+ .option('instance-url', { type: 'string', describe: 'Filter by instance origin' })
15
+ .option('profile-name', { type: 'string', describe: 'Filter by recorded profile name (including removed profiles)' })
16
+ .option('table', { type: 'string', describe: 'Filter by table' })
17
+ .option('operation', { choices: ['create', 'update', 'delete'], type: 'string' })
18
+ .option('record', { type: 'string', describe: 'Filter by full resolved sys_id' })
19
+ .option('limit', { type: 'number', default: 50, describe: 'Maximum entries (1–10000)' });
20
+ return { command: 'history [subcommand]', describe: 'Local seven-day mutation history (not a durable ServiceNow audit)',
21
+ builder: y => filters(y).command({ command: 'list', describe: 'List local mutations', builder: filters, handler: list })
22
+ .command({ command: 'prune', describe: 'Remove expired entries only', handler: wrap(async (_argv, app) => {
23
+ app.ok({ ...pruneHistory(), _formatted: 'Expired local history removed; seven days retained.' });
24
+ }) }), handler: list };
25
+ }
26
+
27
+ export function renderHistory(entries, { details = false, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone } = {}) {
28
+ if (!entries.length) return 'No local mutations in the last seven days.';
29
+ const date = new Intl.DateTimeFormat('en-GB', { timeZone, month: 'short', day: '2-digit',
30
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
31
+ // Strip terminal control characters from stored identifiers and labels.
32
+ // eslint-disable-next-line no-control-regex
33
+ const clean = value => String(value ?? '').replace(/[\x00-\x1f\x7f]/g, ' ');
34
+ const clip = (value, length) => value.length > length ? `${value.slice(0, length - 1)}…` : value;
35
+ const rows = entries.map(entry => {
36
+ let connection = entry.profile;
37
+ if (!connection) {
38
+ try { connection = new URL(entry.instance).host; } catch { connection = entry.instance; }
39
+ }
40
+ return [date.format(new Date(entry.timestamp)), entry.operation, clip(clean(entry.table), 22),
41
+ clip(clean(entry.sys_id || '(unknown)'), 12), entry.outcome, clip(clean(connection), 22)];
42
+ });
43
+ const headers = ['WHEN', 'ACTION', 'TABLE', 'RECORD', 'RESULT', 'CONNECTION'];
44
+ const widths = headers.map((header, i) => Math.max(header.length, ...rows.map(row => row[i].length)));
45
+ const format = row => row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd();
46
+ const lines = [`Recent mutations · ${timeZone}`, '', format(headers)];
47
+ rows.forEach((row, i) => {
48
+ lines.push(format(row));
49
+ if (details) {
50
+ const entry = entries[i];
51
+ lines.push(` ${clean(entry.timestamp)} · ${clean(entry.table)}/${clean(entry.sys_id || '(unknown)')}`,
52
+ ` ${clean(entry.instance)}${entry.reason ? ` · ${clean(entry.reason)}` : ''}`);
53
+ }
54
+ });
55
+ if (!details) lines.push('', 'Use --details for full IDs, URLs and reasons; --json for structured history.');
56
+ return lines.join('\n');
57
+ }
@@ -1,7 +1,5 @@
1
- import { buildTicketCommands } from './_ticket.js';
2
-
3
- const incidentDefaultColumns = ['number', 'short_description', 'priority', 'state', 'assigned_to'];
1
+ import { buildProfileCommand } from '../record-profiles.js';
4
2
 
5
3
  export function incidentsCmd(wrap) {
6
- return buildTicketCommands('incident', 'incidents', 'inc', incidentDefaultColumns, {}, null, wrap);
4
+ return buildProfileCommand('incidents', wrap);
7
5
  }
@@ -1,8 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
- import { input, password as passwordPrompt, select } from '../prompts.js';
4
+ import { input, password as passwordPrompt, select, confirm } from '../prompts.js';
5
5
  import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt, assertSafeExactMatch } from '../helpers.js';
6
+ import { AppError, CodeConfirmationRequired } from '../errors.js';
7
+ import { prepareRemoteMidServer, installRemoteMidServer, startRemoteMidServer, stopRemoteMidServer, logsRemoteMidServer, followRemoteMidServer, removeRemoteMidServer } from '../midserver-remote.js';
8
+ import { prepareWindowsRemoteMidServer, installWindowsRemoteMidServer, startWindowsRemoteMidServer, stopWindowsRemoteMidServer, logsWindowsRemoteMidServer, removeWindowsRemoteMidServer } from '../midserver-windows-remote.js';
9
+ import { prepareLinuxRemoteMidServer, installLinuxRemoteMidServer, startLinuxRemoteMidServer, stopLinuxRemoteMidServer, logsLinuxRemoteMidServer, removeLinuxRemoteMidServer } from '../midserver-linux-remote.js';
10
+ import { downloadRemoteMidRecipe } from '../midserver-remote-recipe.js';
6
11
  import { FormatAuto } from '../output.js';
7
12
  import { declareCapabilities } from '../capabilities.js';
8
13
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
@@ -15,7 +20,7 @@ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'ver
15
20
  const DEFAULT_TIMEOUT = 120000;
16
21
  const DEFAULT_INTERVAL = 2000;
17
22
 
18
- declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop'] });
23
+ declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop', 'validate'] });
19
24
 
20
25
  function agentName(record) {
21
26
  const name = getStringField(record, 'name');
@@ -274,6 +279,13 @@ export function assertMidServerReady(record) {
274
279
  }
275
280
 
276
281
  async function promptMidCredentials(app, argv) {
282
+ if (argv.passwordStdin) {
283
+ if (!argv.username) throw new Error('--password-stdin requires --username');
284
+ const password = fs.readFileSync(0, 'utf8').replace(/\r?\n$/, '');
285
+ if (!password || Buffer.byteLength(password) > 65536 || password.includes('\0')) throw new Error('Invalid password input');
286
+ const validation = await validateMidCredentials(app.getEffectiveInstance(), argv.username, password);
287
+ return { username: argv.username, password, releaseName: validation.releaseName };
288
+ }
277
289
  if (!canPrompt()) throw new Error('MID Server installation is interactive. Run it from a terminal.');
278
290
  let username = argv.username || '';
279
291
  while (true) {
@@ -344,7 +356,31 @@ function removeLocalMidServer(instanceURL, name, directory) {
344
356
  return true;
345
357
  }
346
358
 
359
+ function remoteDirectory(app, argv, name) {
360
+ const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
361
+ const file = ['.jsn-mid-windows-remote.json', '.jsn-mid-linux-remote.json', '.jsn-mid-remote.json'].find(name => fs.existsSync(path.join(directory, name)));
362
+ if (!file) throw new Error('No owned remote MID metadata found');
363
+ const metadata = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
364
+ if (metadata.target !== argv.ssh || metadata.instanceURL !== app.getEffectiveInstance().replace(/\/$/, '') || metadata.name !== name) {
365
+ throw new Error('Remote MID target, instance or name does not match owned local metadata');
366
+ }
367
+ return directory;
368
+ }
369
+
370
+ function nativeRemote(directory) {
371
+ if (fs.existsSync(path.join(directory, '.jsn-mid-windows-remote.json'))) return { start: startWindowsRemoteMidServer, stop: stopWindowsRemoteMidServer, logs: logsWindowsRemoteMidServer, remove: removeWindowsRemoteMidServer };
372
+ if (fs.existsSync(path.join(directory, '.jsn-mid-linux-remote.json'))) return { start: startLinuxRemoteMidServer, stop: stopLinuxRemoteMidServer, logs: logsLinuxRemoteMidServer, remove: removeLinuxRemoteMidServer };
373
+ return null;
374
+ }
375
+
347
376
  async function showMidServerLogs(app, identifier, argv) {
377
+ if (argv.ssh) {
378
+ const directory = remoteDirectory(app, argv, identifier);
379
+ if (argv.follow && nativeRemote(directory)) throw new Error('Remote native logs currently support bounded --tail only');
380
+ if (argv.follow) return followRemoteMidServer({ directory, tail: argv.tail });
381
+ const text = (nativeRemote(directory)?.logs || logsRemoteMidServer)({ directory, tail: argv.tail });
382
+ return app.ok({ target: argv.ssh, text, _formatted: text }, { summary: 'Remote MID logs' });
383
+ }
348
384
  const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
349
385
  const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
350
386
  if (direct) {
@@ -420,7 +456,8 @@ export function midserversCmd(wrap) {
420
456
  builder: (b) => b
421
457
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
422
458
  .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
423
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
459
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
460
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
424
461
  handler: wrap(async (argv, app) => {
425
462
  await showMidServerLogs(app, argv.identifier, argv);
426
463
  }),
@@ -431,16 +468,19 @@ export function midserversCmd(wrap) {
431
468
  builder: (b) => b
432
469
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
433
470
  .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
434
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
471
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
472
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
435
473
  handler: wrap(async (argv, app) => {
436
474
  await showMidServerLogs(app, argv.identifier, argv);
437
475
  }),
438
476
  })
439
477
  .command({
440
478
  command: 'start <identifier>',
441
- describe: 'Start an owned direct MID (Windows: foreground console, no service)',
442
- builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
479
+ describe: 'Start an owned MID locally or over SSH (remote Windows uses a service)',
480
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
481
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
443
482
  handler: wrap(async (argv, app) => {
483
+ if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.start || startRemoteMidServer)({ directory }), { summary: 'Remote MID start command completed' }); }
444
484
  const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
445
485
  if (!metadata) throw new Error('No owned direct MID installation found');
446
486
  if (metadata.state !== 'prepared') throw new Error('MID is already started or has an uncertain runtime state; stop it first');
@@ -450,9 +490,11 @@ export function midserversCmd(wrap) {
450
490
  })
451
491
  .command({
452
492
  command: 'stop <identifier>',
453
- describe: 'Stop an owned Linux direct MID; Windows console uses Ctrl+C',
454
- builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
493
+ describe: 'Stop an owned MID locally or over SSH (local Windows console uses Ctrl+C)',
494
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' })
495
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
455
496
  handler: wrap(async (argv, app) => {
497
+ if (argv.ssh) { const directory = remoteDirectory(app, argv, argv.identifier); return app.ok((nativeRemote(directory)?.stop || stopRemoteMidServer)({ directory }), { summary: 'Remote MID stop command completed' }); }
456
498
  const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
457
499
  if (!metadata) throw new Error('No owned direct MID installation found');
458
500
  app.ok(runDirectInstallation(metadata, 'stop'), { summary: 'Owned native MID stop command completed' });
@@ -463,13 +505,45 @@ export function midserversCmd(wrap) {
463
505
  describe: 'Install and verify a MID Server',
464
506
  builder: (b) => b
465
507
  .option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
466
- .option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
508
+ .option('username', { type: 'string', describe: 'MID Server username' })
509
+ .option('password-stdin', { type: 'boolean', describe: 'Read MID password from stdin instead of a terminal prompt' })
510
+ .option('ssh', { type: 'string', describe: 'Install MID on a configured SSH target (Docker, native Windows service or Linux background process)' })
511
+ .option('native-platform', { type: 'string', choices: ['windows', 'linux'], default: 'windows', describe: 'Remote direct ZIP platform (Linux uses a user background process and needs Python 3)' })
467
512
  .option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
468
513
  .option('directory', { type: 'string', describe: 'Installation directory (must not exist for direct ZIP)' })
469
514
  .option('wait-timeout', { type: 'number', default: 300000, describe: 'How long to wait for the MID Server to report Up' })
470
515
  .option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
471
516
  handler: wrap(async (argv, app) => {
472
517
  app.requireInstance();
518
+ if (argv.ssh) {
519
+ if (!argv.name) throw new Error('Remote installation requires --name');
520
+ const name = normalizeMidName(argv.name);
521
+ const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${name}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }));
522
+ if (existing.length) throw new Error('MID name already exists; refusing to adopt an existing MID');
523
+ const credentials = await promptMidCredentials(app, argv);
524
+ const directory = argv.directory || midInstallDirectory(app.getEffectiveInstance(), name);
525
+ fs.mkdirSync(path.dirname(directory), { recursive: true, mode: 0o700 });
526
+ const native = (argv.installMethod || argv['install-method']) === 'direct';
527
+ let installation;
528
+ if (native) {
529
+ const linux = argv.nativePlatform === 'linux';
530
+ const prepare = linux ? prepareLinuxRemoteMidServer : prepareWindowsRemoteMidServer;
531
+ const install = linux ? installLinuxRemoteMidServer : installWindowsRemoteMidServer;
532
+ const start = linux ? startLinuxRemoteMidServer : startWindowsRemoteMidServer;
533
+ prepare({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
534
+ install({ directory, releaseName: credentials.releaseName, username: credentials.username, password: credentials.password });
535
+ installation = start({ directory });
536
+ } else {
537
+ prepareRemoteMidServer({ target: argv.ssh, instanceURL: app.getEffectiveInstance(), name, directory });
538
+ const context = await downloadRemoteMidRecipe(credentials.releaseName);
539
+ installation = installRemoteMidServer({ directory, context, username: credentials.username, password: credentials.password });
540
+ }
541
+ const kind = native ? (argv.nativePlatform === 'linux' ? 'remote-linux-background' : 'remote-windows-service') : 'remote-docker';
542
+ let record;
543
+ try { record = await waitForMidServerUp(app, name, Math.max(1, argv.waitTimeout), Math.max(500, argv.waitInterval)); }
544
+ catch { return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'pending' }, { summary: 'Remote MID started; registration is pending. Runtime and ownership metadata retained for logs or explicit teardown.' }); }
545
+ return app.ok({ installation: kind, target: argv.ssh, directory, name, registration: 'up', validated: queueValue(record.validated) === 'true', record, runtime_state: installation.state }, { summary: 'Remote MID is Up; validation is a separate explicit operation' });
546
+ }
473
547
  const credentials = await promptMidCredentials(app, argv);
474
548
  const name = argv.name || (await input({
475
549
  message: 'MID Server name:',
@@ -580,8 +654,19 @@ export function midserversCmd(wrap) {
580
654
  aliases: ['delete'],
581
655
  describe: 'Remove a MID Server record (interactive when no identifier is given)',
582
656
  builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
583
- .option('directory', { type: 'string', describe: 'Local installation directory override' }),
657
+ .option('directory', { type: 'string', describe: 'Local installation metadata directory override' })
658
+ .option('ssh', { type: 'string', describe: 'Configured SSH alias for the remote MID target' }),
584
659
  handler: wrap(async (argv, app) => {
660
+ if (argv.ssh) {
661
+ if (!argv.identifier) throw new Error('Remote removal requires the owned MID name');
662
+ const directory = remoteDirectory(app, argv, argv.identifier);
663
+ await confirmDelete(app, argv, `Remove owned MID '${argv.identifier}' from SSH target '${argv.ssh}' and delete its MID record`);
664
+ const removed = (nativeRemote(directory)?.remove || removeRemoteMidServer)({ directory });
665
+ const records = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${argv.identifier}`, sysparm_limit: '2', sysparm_fields: 'sys_id,name' }));
666
+ if (records.length > 1) throw new Error('Remote runtime removed; multiple MID records match. Inspect records before deleting any.');
667
+ if (records.length) await app.sdk.delete(AGENT_TABLE, unwrapSysId(records[0]));
668
+ return app.ok({ name: argv.identifier, target: argv.ssh, remote_runtime_removed: removed.state === 'removed', record_deleted: records.length === 1, metadata_directory: directory }, { summary: 'Owned remote MID runtime removed; nonsecret ownership receipt retained' });
669
+ }
585
670
  let record;
586
671
  if (argv.identifier) {
587
672
  try {
@@ -621,6 +706,28 @@ export function midserversCmd(wrap) {
621
706
  app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved && !direct, local_installation_removed: localRemoved, local_installation_type: direct ? 'direct' : 'docker' }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its owned local runtime' : ''}` });
622
707
  }),
623
708
  })
709
+ .command({
710
+ command: 'validate <identifier>',
711
+ describe: 'Explicitly trust a MID through the platform Validate action; never writes the validated flag directly',
712
+ builder: b => b.option('timeout', { type: 'number', default: 300000, describe: 'Validation verification timeout in milliseconds' }),
713
+ handler: wrap(async (argv, app) => {
714
+ const record = await resolveMidServer(app.sdk, argv.identifier);
715
+ assertMidServerUp(record);
716
+ if (!argv.force) {
717
+ if (!canPrompt() || app.output.getFormat() !== FormatAuto) throw new AppError(CodeConfirmationRequired, 'MID trust validation requires approval.', 'After approval, rerun with --force.');
718
+ if (!await confirm({ message: `Trust MID '${getStringField(record, 'name')}' using the platform Validate action?`, default: false })) return;
719
+ }
720
+ const cookies = await app.sdk._warmSession(), csrf = await app.sdk._getScriptsPageCSRF(cookies);
721
+ await app.sdk.rawRequest(`${app.sdk.baseURL}/xmlhttp.do`, { method: 'POST', headers: { Cookie: cookies, 'X-UserToken': csrf }, body: new URLSearchParams({ sysparm_processor: 'MIDServerAjax', sysparm_name: 'validateMID', sysparm_agent: unwrapSysId(record), sysparm_ck: csrf }).toString() });
722
+ const started = Date.now();
723
+ while (Date.now() - started < argv.timeout) {
724
+ const current = await resolveMidServer(app.sdk, argv.identifier);
725
+ if (queueValue(current.validated) === 'true') return app.ok({ name: getStringField(current, 'name'), validated: true }, { summary: 'MID trust validation verified' });
726
+ await new Promise(resolve => setTimeout(resolve, 2000));
727
+ }
728
+ throw new Error('MID validation was requested but not verified; inspect MID issues before retrying');
729
+ }),
730
+ })
624
731
  .command({
625
732
  command: 'execute [identifier]',
626
733
  describe: 'Execute a shell command through a MID Server',