@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.
- package/README.md +95 -0
- package/package.json +2 -1
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +24 -4
- package/src/cli.js +41 -9
- package/src/command-audience.js +35 -0
- package/src/commands/_generic.js +58 -37
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +38 -31
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/changes.js +2 -4
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/forms.js +22 -97
- package/src/commands/groups.js +11 -8
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +64 -119
- package/src/commands/requests.js +10 -16
- package/src/commands/rest.js +15 -2
- package/src/commands/scan.js +234 -0
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +72 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +31 -22
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/users.js +11 -8
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +11 -2
- package/src/helpers.js +44 -0
- package/src/metadata/records.js +249 -0
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/scan.js +249 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
package/src/commands/forms.js
CHANGED
|
@@ -1,105 +1,29 @@
|
|
|
1
1
|
import { getStringField, interactiveList } from '../helpers.js';
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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: '
|
|
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);
|
package/src/commands/groups.js
CHANGED
|
@@ -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',
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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',
|