@jacebenson/jsn 4.3.13 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/package.json +4 -3
- 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 +12 -0
- package/src/cli.js +45 -9
- package/src/command-audience.js +38 -0
- package/src/commands/_generic.js +45 -28
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +24 -20
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/changes.js +2 -4
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- 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/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/knowledge.js +75 -0
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +57 -104
- package/src/commands/requests.js +9 -15
- 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 +73 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/upgrade.js +69 -0
- 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 +12 -2
- 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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { assertSafeExactMatch } from '../helpers.js';
|
|
2
|
+
import { errUsage, errAuth } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const namePattern = /^[a-zA-Z0-9_]+$/;
|
|
5
|
+
const raw = value => String(value && typeof value === 'object' ? value.value ?? '' : value ?? '');
|
|
6
|
+
const field = (record, name) => raw(record?.[name]);
|
|
7
|
+
const position = row => Number(field(row, 'position')) || 0;
|
|
8
|
+
const ordered = rows => [...rows].sort((a, b) => position(a) - position(b));
|
|
9
|
+
const reference = value => {
|
|
10
|
+
const text = raw(value);
|
|
11
|
+
assertSafeExactMatch(text);
|
|
12
|
+
return text;
|
|
13
|
+
};
|
|
14
|
+
const isName = value => typeof value === 'string' && namePattern.test(value);
|
|
15
|
+
const chunks = values => Array.from({ length: Math.ceil(values.length / 100) }, (_, index) => values.slice(index * 100, (index + 1) * 100));
|
|
16
|
+
|
|
17
|
+
function request(table, query, fields, limit = 1000) {
|
|
18
|
+
return { table, params: new URLSearchParams({
|
|
19
|
+
sysparm_query: query, sysparm_fields: fields, sysparm_limit: String(limit),
|
|
20
|
+
sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
|
|
21
|
+
}) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Invocation-scoped, SDK-bound metadata reader. No persistence across users. */
|
|
25
|
+
export function createRecordMetadataReader(sdk, { limit = 1000, maxDepth = 32 } = {}) {
|
|
26
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10000) throw errUsage('Metadata limit must be an integer from 1 to 10000');
|
|
27
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 100) throw errUsage('Metadata inheritance depth must be an integer from 1 to 100');
|
|
28
|
+
const cache = new Map();
|
|
29
|
+
const reports = new Map();
|
|
30
|
+
|
|
31
|
+
async function reads(specs, warnings) {
|
|
32
|
+
const missing = specs.filter(spec => !cache.has(`${spec.table}?${spec.params}`));
|
|
33
|
+
// Reserve promises before transport so concurrent metadata requests share
|
|
34
|
+
// both successful reads and observed failures within this reader only.
|
|
35
|
+
const unique = [...new Map(missing.map(spec => [`${spec.table}?${spec.params}`, spec])).values()];
|
|
36
|
+
if (unique.length) {
|
|
37
|
+
const pending = (async () => {
|
|
38
|
+
if (unique.length > 1 && typeof sdk.batch === 'function') {
|
|
39
|
+
try {
|
|
40
|
+
const batch = await sdk.batch(unique.map((spec, index) => ({
|
|
41
|
+
id: `metadata-${index}`, method: 'GET', url: `/api/now/table/${spec.table}?${spec.params}`,
|
|
42
|
+
})), { fallback: true });
|
|
43
|
+
return unique.map((spec, index) => {
|
|
44
|
+
const item = batch.results?.find(result => result.id === `metadata-${index}`);
|
|
45
|
+
if (item?.status === 401) throw errAuth('Authentication failed while reading metadata');
|
|
46
|
+
if (!item?.ok) return { rows: [], error: item?.error?.message || `Metadata read failed (${item?.status || 'unknown status'})` };
|
|
47
|
+
return normalize(item.data?.result, spec);
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code === 'auth_error' || error.status === 401) throw error;
|
|
51
|
+
// The batch provider owns unsupported-endpoint fallback. Never
|
|
52
|
+
// replay authentication/ACL/server failures through another path.
|
|
53
|
+
return unique.map(() => ({ rows: [], error: error.message || 'Metadata batch failed' }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Promise.all(unique.map(async spec => {
|
|
57
|
+
try { return normalize(await sdk.list(spec.table, spec.params), spec); }
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === 'auth_error' || error.status === 401) throw error;
|
|
60
|
+
return { rows: [], error: error.message || 'Metadata read failed' };
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
})();
|
|
64
|
+
unique.forEach((spec, index) => cache.set(`${spec.table}?${spec.params}`, pending.then(results => results[index])));
|
|
65
|
+
}
|
|
66
|
+
return Promise.all(specs.map(async spec => {
|
|
67
|
+
const result = await cache.get(`${spec.table}?${spec.params}`);
|
|
68
|
+
if (result.error) warnings.push({ source: spec.table, reason: result.error });
|
|
69
|
+
if (result.truncated) warnings.push({ source: spec.table, reason: `Metadata limit ${spec.params.get('sysparm_limit')} reached; results may be incomplete` });
|
|
70
|
+
return result.rows;
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalize(rows, spec) {
|
|
75
|
+
if (!Array.isArray(rows)) return { rows: [], error: 'Metadata response was not an array' };
|
|
76
|
+
return { rows, truncated: rows.length >= Number(spec.params.get('sysparm_limit')) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function read({ table, view = 'Default view' }) {
|
|
80
|
+
if (!isName(table)) throw errUsage('Metadata table must be a table name containing letters, numbers or underscores');
|
|
81
|
+
if (typeof view !== 'string' || !view) throw errUsage('Metadata view must be a nonempty name, title or sys_id');
|
|
82
|
+
assertSafeExactMatch(view);
|
|
83
|
+
const key = `${table}\0${view}`;
|
|
84
|
+
if (!reports.has(key)) reports.set(key, load(table, view));
|
|
85
|
+
// Protect cached objects from a consumer attaching record-specific hints.
|
|
86
|
+
return structuredClone(await reports.get(key));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function load(table, view) {
|
|
90
|
+
const warnings = [];
|
|
91
|
+
const hierarchy = [];
|
|
92
|
+
const visited = new Set();
|
|
93
|
+
let next = request('sys_db_object', `name=${table}`, 'sys_id,name,super_class', 2);
|
|
94
|
+
for (let depth = 0; next && depth < maxDepth; depth++) {
|
|
95
|
+
const [rows] = await reads([next], warnings);
|
|
96
|
+
if (rows.length !== 1) {
|
|
97
|
+
warnings.push({ source: 'sys_db_object', reason: `Expected one visible table definition, found ${rows.length}; inheritance coverage is incomplete` });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const row = rows[0];
|
|
101
|
+
const name = field(row, 'name');
|
|
102
|
+
const id = field(row, 'sys_id');
|
|
103
|
+
if (!isName(name) || visited.has(name)) {
|
|
104
|
+
warnings.push({ source: 'sys_db_object', reason: 'Invalid or cyclic table inheritance metadata' });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
visited.add(name);
|
|
108
|
+
hierarchy.push({ name, sys_id: id });
|
|
109
|
+
if (!Object.hasOwn(row, 'super_class')) warnings.push({ source: 'sys_db_object', reason: 'Parent metadata is not visible; inheritance coverage is incomplete' });
|
|
110
|
+
const parent = reference(row.super_class);
|
|
111
|
+
next = parent ? request('sys_db_object', `sys_id=${parent}`, 'sys_id,name,super_class', 2) : null;
|
|
112
|
+
}
|
|
113
|
+
if (next && hierarchy.length === maxDepth) warnings.push({ source: 'sys_db_object', reason: `Inheritance depth limit ${maxDepth} reached` });
|
|
114
|
+
if (!hierarchy.length) hierarchy.push({ name: table, sys_id: '' });
|
|
115
|
+
const names = hierarchy.map(item => item.name);
|
|
116
|
+
const defaultView = view === 'Default view' || view === 'default';
|
|
117
|
+
const viewQuery = defaultView ? 'name=^ORname=default^ORtitle=Default view' : `name=${view}^ORtitle=${view}^ORsys_id=${view}`;
|
|
118
|
+
const [views] = await reads([request('sys_ui_view', `${viewQuery}^userISEMPTY`, 'sys_id,name,title', limit)], warnings);
|
|
119
|
+
const byId = views.filter(row => field(row, 'sys_id') === view);
|
|
120
|
+
const byName = views.filter(row => field(row, 'name') === (defaultView ? '' : view));
|
|
121
|
+
const byTitle = views.filter(row => field(row, 'title') === view);
|
|
122
|
+
const byAlias = defaultView ? views.filter(row => field(row, 'name') === 'default') : [];
|
|
123
|
+
const candidates = byId.length ? byId : byName.length ? byName : byTitle.length ? byTitle : byAlias;
|
|
124
|
+
let viewId = '';
|
|
125
|
+
const viewCapped = views.length >= limit;
|
|
126
|
+
if (candidates.length === 1 && !viewCapped) {
|
|
127
|
+
viewId = reference(candidates[0].sys_id);
|
|
128
|
+
if (!viewId) warnings.push({ source: 'sys_ui_view', reason: 'View id is absent or not visible' });
|
|
129
|
+
} else if (candidates.length > 1 || viewCapped) warnings.push({ source: 'sys_ui_view', reason: 'View lookup is ambiguous or capped; no layout was selected' });
|
|
130
|
+
else if (!defaultView) warnings.push({ source: 'sys_ui_view', reason: 'Requested view is absent or not visible; no layout was selected' });
|
|
131
|
+
const canReadLayout = !viewCapped && candidates.length <= 1 && (defaultView || !!viewId);
|
|
132
|
+
const viewFilter = viewId ? `view=${viewId}${defaultView ? '^ORviewISEMPTY' : ''}` : 'viewISEMPTY';
|
|
133
|
+
const specs = [
|
|
134
|
+
request('sys_dictionary', `referenceIN${names.join(',')}^elementISNOTEMPTY`, 'sys_id,name,element,reference,column_label', limit),
|
|
135
|
+
request('sys_relationship', `basic_apply_toIN${names.join(',')}`, 'sys_id,name,basic_apply_to,basic_query_from,reference_field,simple_reference,advanced,query_with', limit),
|
|
136
|
+
...(canReadLayout ? [
|
|
137
|
+
request('sys_ui_form', `nameIN${names.join(',')}^${viewFilter}^sys_userISEMPTY`, 'sys_id,name,view', limit),
|
|
138
|
+
request('sys_ui_related_list', `nameIN${names.join(',')}^${viewFilter}^sys_userISEMPTY`, 'sys_id,name,view', limit),
|
|
139
|
+
] : []),
|
|
140
|
+
];
|
|
141
|
+
const [dictionary, relationships, forms = [], containers = []] = await reads(specs, warnings);
|
|
142
|
+
const nearest = rows => {
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
const candidates = rows.filter(row => field(row, 'name') === name);
|
|
145
|
+
const exact = viewId ? candidates.filter(row => field(row, 'view') === viewId) : [];
|
|
146
|
+
const preferred = exact.length ? exact : candidates;
|
|
147
|
+
if (preferred.length > 1) warnings.push({ source: 'layout', reason: `Ambiguous layout for ${name}; no layout was selected` });
|
|
148
|
+
if (preferred.length) {
|
|
149
|
+
if (!field(preferred[0], 'sys_id')) {
|
|
150
|
+
warnings.push({ source: 'layout', reason: `Layout id is absent or not visible for ${name}` });
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return preferred.length === 1 ? preferred[0] : null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
};
|
|
158
|
+
const form = nearest(forms);
|
|
159
|
+
const container = nearest(containers);
|
|
160
|
+
const detailSpecs = [
|
|
161
|
+
...(form ? [request('sys_ui_form_section', `sys_ui_form=${reference(form.sys_id)}^ORDERBYposition`, 'sys_id,sys_ui_form,sys_ui_section,position', limit)] : []),
|
|
162
|
+
...(container ? [request('sys_ui_related_list_entry', `list_id=${reference(container.sys_id)}^ORDERBYposition`, 'sys_id,related_list,position,filter,order_by', limit)] : []),
|
|
163
|
+
];
|
|
164
|
+
const details = await reads(detailSpecs, warnings);
|
|
165
|
+
const links = form ? details.shift() : [];
|
|
166
|
+
const entries = container ? details.shift() : [];
|
|
167
|
+
const sectionIds = [...new Set(links.map(link => reference(link.sys_ui_section)).filter(Boolean))];
|
|
168
|
+
const relationshipIds = entries.map(entry => field(entry, 'related_list')).filter(value => /^REL:[a-f0-9]{32}$/i.test(value)).map(value => value.slice(4));
|
|
169
|
+
const missingRelationships = relationshipIds.filter(id => !relationships.some(row => field(row, 'sys_id') === id));
|
|
170
|
+
const tailSpecs = [
|
|
171
|
+
...chunks(sectionIds).flatMap(ids => [
|
|
172
|
+
{ ...request('sys_ui_section', `sys_idIN${ids.join(',')}^sys_userISEMPTY`, 'sys_id,name,caption,header,title,view', limit), group: 'sections' },
|
|
173
|
+
{ ...request('sys_ui_element', `sys_ui_sectionIN${ids.join(',')}^sys_userISEMPTY^ORDERBYposition`, 'sys_id,sys_ui_section,element,type,position', limit), group: 'elements' },
|
|
174
|
+
]),
|
|
175
|
+
...chunks([...new Set(missingRelationships)]).map(ids => ({
|
|
176
|
+
...request('sys_relationship', `sys_idIN${ids.join(',')}`, 'sys_id,name,basic_apply_to,basic_query_from,reference_field,simple_reference,advanced,query_with', limit), group: 'relationships',
|
|
177
|
+
})),
|
|
178
|
+
];
|
|
179
|
+
const tail = await reads(tailSpecs, warnings);
|
|
180
|
+
const group = name => tail.flatMap((rows, index) => tailSpecs[index].group === name ? rows : []);
|
|
181
|
+
const sections = group('sections');
|
|
182
|
+
const elements = group('elements');
|
|
183
|
+
relationships.push(...group('relationships'));
|
|
184
|
+
const sectionsOut = ordered(links).map(link => {
|
|
185
|
+
const id = field(link, 'sys_ui_section');
|
|
186
|
+
const section = sections.find(row => field(row, 'sys_id') === id);
|
|
187
|
+
if (!section) warnings.push({ source: 'sys_ui_section', reason: `A linked section is absent or not visible` });
|
|
188
|
+
return { sys_id: id, caption: field(section, 'caption'), position: position(link),
|
|
189
|
+
elements: ordered(elements.filter(row => field(row, 'sys_ui_section') === id)).map(row => ({
|
|
190
|
+
element: field(row, 'element'), type: field(row, 'type'), label: field(row, 'label'), position: position(row),
|
|
191
|
+
})) };
|
|
192
|
+
});
|
|
193
|
+
const relatedLists = buildRelationships({ entries, dictionary, relationships, sourceTable: container ? field(container, 'name') : '', names, warnings });
|
|
194
|
+
return {
|
|
195
|
+
table, requested_view: view, view_sys_id: viewId, hierarchy,
|
|
196
|
+
form: form ? { sys_id: field(form, 'sys_id'), table: field(form, 'name'), inherited: field(form, 'name') !== table, sections: sectionsOut } : null,
|
|
197
|
+
related_lists: relatedLists,
|
|
198
|
+
coverage: { state: warnings.length ? 'partial' : 'observed', scope: 'Visible shared layout metadata only; ACLs, personalization and advanced relationships may hide or change runtime results', warnings },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { read, clear() { cache.clear(); reports.clear(); } };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildRelationships({ entries, dictionary, relationships, sourceTable, names, warnings }) {
|
|
206
|
+
const result = new Map();
|
|
207
|
+
for (const row of dictionary) {
|
|
208
|
+
const table = field(row, 'name');
|
|
209
|
+
const referenceField = field(row, 'element');
|
|
210
|
+
if (!isName(table) || !isName(referenceField)) {
|
|
211
|
+
warnings.push({ source: 'sys_dictionary', reason: 'Ignored an invalid reference definition' });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!names.includes(field(row, 'reference'))) warnings.push({ source: 'sys_dictionary', reason: 'A reference target is absent, unrelated, or not visible' });
|
|
215
|
+
const id = `${table}.${referenceField}`;
|
|
216
|
+
result.set(id, { id, kind: 'reference', table, reference_field: referenceField,
|
|
217
|
+
references: field(row, 'reference'), label: field(row, 'column_label'), configured: false,
|
|
218
|
+
query_status: names.includes(field(row, 'reference')) ? 'reference' : 'unavailable' });
|
|
219
|
+
}
|
|
220
|
+
for (const row of relationships) {
|
|
221
|
+
const id = `REL:${field(row, 'sys_id')}`;
|
|
222
|
+
if (!/^REL:[a-f0-9]{32}$/i.test(id)) continue;
|
|
223
|
+
const table = field(row, 'basic_query_from');
|
|
224
|
+
const referenceField = field(row, 'reference_field');
|
|
225
|
+
const queryable = field(row, 'advanced') !== 'true' && field(row, 'advanced') !== '1'
|
|
226
|
+
&& names.includes(field(row, 'basic_apply_to'))
|
|
227
|
+
&& ['true', '1'].includes(field(row, 'simple_reference')) && !field(row, 'query_with').trim()
|
|
228
|
+
&& isName(table) && isName(referenceField);
|
|
229
|
+
result.set(id, { id, kind: 'relationship', relationship_sys_id: field(row, 'sys_id'),
|
|
230
|
+
table: isName(table) ? table : '', reference_field: queryable ? referenceField : '',
|
|
231
|
+
label: field(row, 'name'), configured: false, query_status: queryable ? 'reference' : 'platform_required',
|
|
232
|
+
...(queryable ? {} : { reason: 'Custom/advanced relationship requires platform query logic; scripts are never executed by metadata discovery' }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
for (const entry of ordered(entries)) {
|
|
236
|
+
const id = field(entry, 'related_list');
|
|
237
|
+
const existing = result.get(id);
|
|
238
|
+
const descriptor = existing || { id, kind: 'unresolved', table: '', reference_field: '', query_status: 'unavailable',
|
|
239
|
+
reason: 'Configured related list metadata is absent, unsupported, or not visible' };
|
|
240
|
+
const filter = entry.filter;
|
|
241
|
+
const filterAvailable = Boolean(Object.hasOwn(entry, 'filter') && (filter === null || typeof filter === 'string'
|
|
242
|
+
|| (filter && typeof filter === 'object' && Object.hasOwn(filter, 'value') && (filter.value === null || typeof filter.value === 'string'))));
|
|
243
|
+
if (!filterAvailable) warnings.push({ source: 'sys_ui_related_list_entry', reason: 'A configured filter is absent or not visible; do not assume an unfiltered list' });
|
|
244
|
+
result.set(id, { ...descriptor, configured: true, filter_available: filterAvailable, source_table: sourceTable, position: position(entry),
|
|
245
|
+
filter: field(entry, 'filter'), order_by: field(entry, 'order_by') });
|
|
246
|
+
if (!existing) warnings.push({ source: 'sys_ui_related_list_entry', reason: descriptor.reason });
|
|
247
|
+
}
|
|
248
|
+
return [...result.values()].sort((a, b) => Number(b.configured) - Number(a.configured) || (a.position || 0) - (b.position || 0));
|
|
249
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { validateRemoteTarget } from './midserver-remote.js';
|
|
7
|
+
import { buildMidZipUrl } from './midserver-direct.js';
|
|
8
|
+
|
|
9
|
+
const FILE = '.jsn-mid-linux-remote.json';
|
|
10
|
+
// Static program only in argv; all deployment data and credentials travel on stdin.
|
|
11
|
+
const PROGRAM = String.raw`
|
|
12
|
+
import os,sys,json,stat,platform,shutil,subprocess,time,zipfile,urllib.request,hashlib,re,xml.etree.ElementTree as ET
|
|
13
|
+
p=json.load(sys.stdin)
|
|
14
|
+
def fail(): raise RuntimeError('Remote native MID safety check failed')
|
|
15
|
+
def result(v): print('JSN_RESULT:'+json.dumps(v))
|
|
16
|
+
def private(d):
|
|
17
|
+
s=os.lstat(d)
|
|
18
|
+
if not stat.S_ISDIR(s.st_mode) or s.st_uid!=os.getuid() or s.st_mode&0o077: fail()
|
|
19
|
+
def ancestors(d):
|
|
20
|
+
while d!='/':
|
|
21
|
+
s=os.lstat(d)
|
|
22
|
+
if not stat.S_ISDIR(s.st_mode) or (s.st_mode&0o022 and not s.st_mode&stat.S_ISVTX): fail()
|
|
23
|
+
d=os.path.dirname(d)
|
|
24
|
+
def owned():
|
|
25
|
+
private(p['remoteDirectory']); ancestors(p['remoteDirectory'])
|
|
26
|
+
f=os.path.join(p['remoteDirectory'],'.jsn-owner.json'); s=os.lstat(f)
|
|
27
|
+
if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.getuid() or s.st_mode&0o077: fail()
|
|
28
|
+
m=json.load(open(f))
|
|
29
|
+
if any(m.get(k)!=p.get(k) for k in ['owner','instanceURL','name','remoteDirectory']): fail()
|
|
30
|
+
def processes():
|
|
31
|
+
found=[]; agent=os.path.join(p['remoteDirectory'],'agent')
|
|
32
|
+
for entry in os.listdir('/proc'):
|
|
33
|
+
if not entry.isdigit(): continue
|
|
34
|
+
try:
|
|
35
|
+
proc='/proc/'+entry
|
|
36
|
+
if os.stat(proc).st_uid!=os.getuid(): continue
|
|
37
|
+
exe=os.readlink(proc+'/exe'); cwd=os.readlink(proc+'/cwd')
|
|
38
|
+
if exe in [agent+'/bin/wrapper-linux-x86-64',agent+'/jre/bin/java'] and cwd==agent: found.append(int(entry))
|
|
39
|
+
except (FileNotFoundError,PermissionError,ProcessLookupError): continue
|
|
40
|
+
return found
|
|
41
|
+
def safe_tree():
|
|
42
|
+
for root,dirs,files in os.walk(p['remoteDirectory'],followlinks=False):
|
|
43
|
+
for name in dirs+files:
|
|
44
|
+
s=os.lstat(os.path.join(root,name))
|
|
45
|
+
if not (stat.S_ISREG(s.st_mode) or stat.S_ISDIR(s.st_mode)) or s.st_uid!=os.getuid(): fail()
|
|
46
|
+
def launcher(action):
|
|
47
|
+
owned(); safe_tree()
|
|
48
|
+
if action=='stop':
|
|
49
|
+
pidfile=os.path.join(p['remoteDirectory'],'agent','work','jsn_mid_'+p['owner'].replace('-','')+'.pid')
|
|
50
|
+
s=os.lstat(pidfile)
|
|
51
|
+
if not stat.S_ISREG(s.st_mode) or s.st_uid!=os.getuid() or s.st_size>64: fail()
|
|
52
|
+
value=open(pidfile).read().strip()
|
|
53
|
+
if not re.fullmatch('[1-9][0-9]*',value) or int(value) not in processes(): fail()
|
|
54
|
+
subprocess.run(['sh','bin/mid.sh',action],cwd=os.path.join(p['remoteDirectory'],'agent'),stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=90,start_new_session=True)
|
|
55
|
+
if platform.system()!='Linux' or platform.machine() not in ['x86_64','amd64'] or os.getuid()==0: fail()
|
|
56
|
+
action=p['action']; d=p.get('remoteDirectory')
|
|
57
|
+
if action=='prepare':
|
|
58
|
+
home=os.path.expanduser('~'); ancestors(home)
|
|
59
|
+
if os.lstat(home).st_uid!=os.getuid(): fail()
|
|
60
|
+
d=os.path.join(home,'.jsn-mid',p['owner'])
|
|
61
|
+
if os.path.lexists(d): fail()
|
|
62
|
+
result(dict(remoteDirectory=d,platform='linux-x64',elevated=False,requiresElevation=False,runtime='user-background',freeBytes=shutil.disk_usage(home).free,python=platform.python_version()))
|
|
63
|
+
elif action=='install':
|
|
64
|
+
if os.path.lexists(d): fail()
|
|
65
|
+
parent=os.path.dirname(d)
|
|
66
|
+
if not os.path.exists(parent): os.mkdir(parent,0o700)
|
|
67
|
+
private(parent); ancestors(parent)
|
|
68
|
+
if shutil.disk_usage(parent).free<3*1024**3: fail()
|
|
69
|
+
os.mkdir(d,0o700); os.umask(0o077)
|
|
70
|
+
marker={k:p[k] for k in ['owner','instanceURL','name','remoteDirectory']}
|
|
71
|
+
with open(os.path.join(d,'.jsn-owner.json'),'x') as f: json.dump(marker,f)
|
|
72
|
+
archive=os.path.join(d,'.download.zip')
|
|
73
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
74
|
+
def redirect_request(self,*args): fail()
|
|
75
|
+
opener=urllib.request.build_opener(NoRedirect)
|
|
76
|
+
total=0; digest=hashlib.sha256()
|
|
77
|
+
with opener.open(p['archiveURL'],timeout=600) as source,open(archive,'xb') as dest:
|
|
78
|
+
while True:
|
|
79
|
+
chunk=source.read(1024*1024)
|
|
80
|
+
if not chunk: break
|
|
81
|
+
total+=len(chunk)
|
|
82
|
+
if total>1024**3: fail()
|
|
83
|
+
digest.update(chunk); dest.write(chunk)
|
|
84
|
+
with zipfile.ZipFile(archive) as z:
|
|
85
|
+
entries=z.infolist(); seen=set(); expanded=0
|
|
86
|
+
if len(entries)>20000: fail()
|
|
87
|
+
for e in entries:
|
|
88
|
+
n=e.filename; parts=n.rstrip('/').split('/'); mode=e.external_attr>>16; kind=stat.S_IFMT(mode)
|
|
89
|
+
if not parts or parts[0] not in ['agent','META-INF'] or '\\' in n or any(x in ['','.','..'] or any(ord(c)<32 for c in x) for x in parts) or kind not in [0,stat.S_IFDIR,stat.S_IFREG]: fail()
|
|
90
|
+
if n.rstrip('/') in seen: fail()
|
|
91
|
+
seen.add(n.rstrip('/')); expanded+=e.file_size
|
|
92
|
+
if expanded>2*1024**3: fail()
|
|
93
|
+
for e in entries:
|
|
94
|
+
dest=os.path.join(d,e.filename)
|
|
95
|
+
if e.is_dir(): os.makedirs(dest,mode=0o700,exist_ok=True); continue
|
|
96
|
+
os.makedirs(os.path.dirname(dest),mode=0o700,exist_ok=True)
|
|
97
|
+
with z.open(e) as source,open(dest,'xb') as target: shutil.copyfileobj(source,target,1024*1024)
|
|
98
|
+
os.chmod(dest,0o700 if (e.external_attr>>16)&0o111 else 0o600)
|
|
99
|
+
agent=os.path.join(d,'agent')
|
|
100
|
+
for name in ['config.xml','conf/wrapper.conf','conf/wrapper-override.conf','bin/mid.sh','bin/wrapper-linux-x86-64','jre/bin/java']:
|
|
101
|
+
if not os.path.isfile(os.path.join(agent,name)): fail()
|
|
102
|
+
wrapper=open(os.path.join(agent,'conf/wrapper.conf')).read()
|
|
103
|
+
script=open(os.path.join(agent,'bin/mid.sh')).read()
|
|
104
|
+
if 'wrapper.java.command=jre/bin/java' not in wrapper or 'wrapper-override.conf' not in wrapper or 'SERVICE_MANAGEMENT_TOOL' not in script: fail()
|
|
105
|
+
config=os.path.join(agent,'config.xml'); tree=ET.parse(config,parser=ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))); root=tree.getroot()
|
|
106
|
+
if root.tag!='parameters': fail()
|
|
107
|
+
for key,value in {'url':p['instanceURL']+'/','name':p['name'],'mid.instance.username':p['username'],'mid.instance.password':p['password']}.items():
|
|
108
|
+
nodes=[e for e in root.findall('parameter') if e.get('name')==key]
|
|
109
|
+
if len(nodes)>1: fail()
|
|
110
|
+
e=nodes[0] if nodes else ET.SubElement(root,'parameter',{'name':key}); e.set('value',value)
|
|
111
|
+
if key=='mid.instance.password': e.set('secure','true')
|
|
112
|
+
tree.write(config,encoding='utf-8',xml_declaration=True)
|
|
113
|
+
token='jsn_mid_'+p['owner'].replace('-','')
|
|
114
|
+
with open(os.path.join(agent,'conf/wrapper-override.conf'),'a') as f: f.write('\nwrapper.name='+token+'\nwrapper.displayname=ServiceNow MID '+token+'\n')
|
|
115
|
+
with open(os.path.join(agent,'bin/mid.shconf_override'),'a') as f: f.write('\nAPP_NAME="'+token+'"\nAPP_LONG_NAME="ServiceNow MID '+token+'"\nSERVICE_MANAGEMENT_TOOL=initd\n')
|
|
116
|
+
os.unlink(archive); result(dict(installed=True,archiveSha256=digest.hexdigest(),status='stopped'))
|
|
117
|
+
else:
|
|
118
|
+
owned()
|
|
119
|
+
if action=='start':
|
|
120
|
+
if processes(): fail()
|
|
121
|
+
launcher('start'); time.sleep(2)
|
|
122
|
+
if not processes(): fail()
|
|
123
|
+
elif action=='stop':
|
|
124
|
+
if processes(): launcher('stop')
|
|
125
|
+
for attempt in range(60):
|
|
126
|
+
if not processes(): break
|
|
127
|
+
time.sleep(1)
|
|
128
|
+
if processes(): fail()
|
|
129
|
+
elif action=='remove':
|
|
130
|
+
if processes(): fail()
|
|
131
|
+
safe_tree(); shutil.rmtree(d); result(dict(removed=True)); sys.exit(0)
|
|
132
|
+
elif action=='logs':
|
|
133
|
+
safe_tree()
|
|
134
|
+
directory=os.path.join(d,'agent','logs'); candidates=[]
|
|
135
|
+
if os.path.isdir(directory):
|
|
136
|
+
for n in os.listdir(directory):
|
|
137
|
+
if re.fullmatch(r'agent0\.log(?:\.[0-9]+)?',n) or n=='wrapper.log':
|
|
138
|
+
f=os.path.join(directory,n); s=os.lstat(f)
|
|
139
|
+
if stat.S_ISREG(s.st_mode) and s.st_uid==os.getuid(): candidates.append((n!='wrapper.log',s.st_mtime,f))
|
|
140
|
+
text=''
|
|
141
|
+
if candidates:
|
|
142
|
+
with open(max(candidates)[2],'rb') as f:
|
|
143
|
+
f.seek(max(0,os.fstat(f.fileno()).st_size-1024*1024)); text=f.read().decode('utf-8','replace')
|
|
144
|
+
result(dict(logs='\n'.join(text.splitlines()[-p['tail']:]))); sys.exit(0)
|
|
145
|
+
elif action!='inspect': fail()
|
|
146
|
+
ids=processes(); result(dict(status='running' if ids else 'stopped',pids=ids,runtime='user-background',requiresElevation=False))
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
function remote(target, payload, { run = execFileSync, timeout = 30000 } = {}) {
|
|
150
|
+
validateRemoteTarget(target);
|
|
151
|
+
const encoded = Buffer.from(PROGRAM).toString('base64');
|
|
152
|
+
const command = `python3 -c 'import base64;exec(base64.b64decode("${encoded}"))'`;
|
|
153
|
+
let output;
|
|
154
|
+
try { output = String(run('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'RemoteCommand=none', '-o', 'RequestTTY=no', '--', target, command], { input: JSON.stringify(payload), timeout, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] })); }
|
|
155
|
+
catch { throw new Error('Remote Linux MID operation failed; Python 3 and a non-root Linux x64 account are required. Inspect owned installation before retrying.'); }
|
|
156
|
+
const line = output.split(/\r?\n/).findLast(value => value.startsWith('JSN_RESULT:'));
|
|
157
|
+
try { if (!line) throw new Error(); return JSON.parse(line.slice(11)); }
|
|
158
|
+
catch { throw new Error('Remote Linux MID returned no verified result'); }
|
|
159
|
+
}
|
|
160
|
+
function save(directory, m) { fs.writeFileSync(path.join(directory, FILE), JSON.stringify(m, null, 2), { mode: 0o600 }); fs.chmodSync(path.join(directory, FILE), 0o600); }
|
|
161
|
+
function load(directory) {
|
|
162
|
+
const dir = fs.lstatSync(directory), file = fs.lstatSync(path.join(directory, FILE));
|
|
163
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || (dir.mode & 0o077) || (file.mode & 0o077)) throw new Error('Linux MID metadata must be private');
|
|
164
|
+
const m = JSON.parse(fs.readFileSync(path.join(directory, FILE), 'utf8')); validateRemoteTarget(m.target);
|
|
165
|
+
if (m.version !== 1 || !/^[a-f0-9-]{36}$/.test(m.owner) || !m.remoteDirectory?.startsWith('/') || m.remoteDirectory !== path.posix.normalize(m.remoteDirectory) || !m.remoteDirectory.endsWith(`/.jsn-mid/${m.owner}`)) throw new Error('Invalid Linux MID ownership metadata');
|
|
166
|
+
return m;
|
|
167
|
+
}
|
|
168
|
+
export function prepareLinuxRemoteMidServer({ target, instanceURL, name, directory, platform = process.platform, ...options }) {
|
|
169
|
+
if (platform === 'win32') throw new Error('A POSIX controller is required for private local metadata');
|
|
170
|
+
validateRemoteTarget(target);
|
|
171
|
+
const url = new URL(instanceURL);
|
|
172
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || '')) throw new Error('Valid HTTPS origin and MID name required');
|
|
173
|
+
if (fs.existsSync(directory)) throw new Error('Local deployment directory exists; no adoption');
|
|
174
|
+
const owner = randomUUID();
|
|
175
|
+
const report = remote(target, { action: 'prepare', owner }, options);
|
|
176
|
+
if (report.platform !== 'linux-x64' || report.requiresElevation !== false || report.freeBytes < 3 * 1024 ** 3 || !report.remoteDirectory?.endsWith(`/.jsn-mid/${owner}`)) throw new Error('Linux preflight failed');
|
|
177
|
+
const m = { version: 1, target, owner, instanceURL: url.origin, name, ...report, state: 'prepared' };
|
|
178
|
+
fs.mkdirSync(directory, { mode: 0o700 }); fs.chmodSync(directory, 0o700); save(directory, m); return m;
|
|
179
|
+
}
|
|
180
|
+
export function installLinuxRemoteMidServer({ directory, releaseName, username, password, ...options }) {
|
|
181
|
+
const m = load(directory);
|
|
182
|
+
if (m.state !== 'prepared' || !username || !password || [...String(username) + String(password)].some(char => char.codePointAt(0) < 32 && !['\t', '\n', '\r'].includes(char))) throw new Error('Fresh deployment and valid credentials required');
|
|
183
|
+
const archiveURL = buildMidZipUrl(releaseName, 'linux');
|
|
184
|
+
m.state = 'installing'; save(directory, m);
|
|
185
|
+
try {
|
|
186
|
+
const result = remote(m.target, { ...m, action: 'install', archiveURL, username, password }, { ...options, timeout: 1200000 });
|
|
187
|
+
if (!result.installed || !/^[a-f0-9]{64}$/i.test(result.archiveSha256 || '')) throw new Error('Installation unverified');
|
|
188
|
+
Object.assign(m, { state: 'installed', releaseName, archiveSha256: result.archiveSha256 }); save(directory, m); return m;
|
|
189
|
+
} catch (error) { m.state = 'uncertain'; save(directory, m); throw error; }
|
|
190
|
+
}
|
|
191
|
+
function operation(action, { directory, ...options }) {
|
|
192
|
+
const m = load(directory);
|
|
193
|
+
try {
|
|
194
|
+
const result = remote(m.target, { ...m, action }, { ...options, timeout: action === 'start' || action === 'stop' ? 180000 : 30000 });
|
|
195
|
+
if ((action === 'start' && result.status !== 'running') || (action === 'stop' && result.status !== 'stopped')) throw new Error('Runtime state unverified');
|
|
196
|
+
if (action === 'remove' && result.removed !== true) throw new Error('Removal unverified');
|
|
197
|
+
m.state = action === 'remove' ? 'removed' : result.status; save(directory, m); return { ...m, ...result };
|
|
198
|
+
} catch (error) { m.state = 'uncertain'; save(directory, m); throw error; }
|
|
199
|
+
}
|
|
200
|
+
export const inspectLinuxRemoteMidServer = options => operation('inspect', options);
|
|
201
|
+
export const startLinuxRemoteMidServer = options => operation('start', options);
|
|
202
|
+
export const stopLinuxRemoteMidServer = options => operation('stop', options);
|
|
203
|
+
export function removeLinuxRemoteMidServer(options) {
|
|
204
|
+
stopLinuxRemoteMidServer(options);
|
|
205
|
+
return operation('remove', options);
|
|
206
|
+
}
|
|
207
|
+
export function logsLinuxRemoteMidServer({ directory, tail = 100, ...options }) {
|
|
208
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > 10000) throw new Error('Log tail must be 1–10000');
|
|
209
|
+
const m = load(directory); return remote(m.target, { ...m, action: 'logs', tail }, options).logs;
|
|
210
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { buildMidRecipeUrl } from './midserver-install.js';
|
|
5
|
+
import { inspectMidZip, extractMidZip } from './midserver-zip.js';
|
|
6
|
+
|
|
7
|
+
const LIMIT = 64 * 1024 * 1024;
|
|
8
|
+
export function recipeTar(directory) {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let total = 0;
|
|
11
|
+
function add(folder, prefix = '') {
|
|
12
|
+
for (const name of fs.readdirSync(folder).sort()) {
|
|
13
|
+
const filename = path.join(folder, name), relative = prefix + name, stat = fs.lstatSync(filename);
|
|
14
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) throw new Error('Recipe contains a link or special file');
|
|
15
|
+
if (stat.isDirectory()) { add(filename, `${relative}/`); continue; }
|
|
16
|
+
const split = relative.lastIndexOf('/');
|
|
17
|
+
const leaf = Buffer.byteLength(relative) <= 100 ? relative : relative.slice(split + 1);
|
|
18
|
+
const parent = Buffer.byteLength(relative) <= 100 ? '' : relative.slice(0, split);
|
|
19
|
+
if (Buffer.byteLength(leaf) > 100 || Buffer.byteLength(parent) > 155 || split < 0 && Buffer.byteLength(relative) > 100) throw new Error('Recipe path exceeds tar limits');
|
|
20
|
+
total += stat.size; if (total > LIMIT) throw new Error('MID recipe exceeds 64 MiB');
|
|
21
|
+
const header = Buffer.alloc(512);
|
|
22
|
+
const write = (value, offset, length) => header.write(String(value), offset, length, 'utf8');
|
|
23
|
+
const octal = (value, offset, length) => write(value.toString(8).padStart(length - 1, '0') + '\0', offset, length);
|
|
24
|
+
write(leaf, 0, 100); octal(stat.mode & 0o111 ? 0o755 : 0o644, 100, 8); octal(0, 108, 8); octal(0, 116, 8);
|
|
25
|
+
octal(stat.size, 124, 12); octal(0, 136, 12); header.fill(32, 148, 156); write('0', 156, 1); write('ustar\0', 257, 6); write('00', 263, 2); write(parent, 345, 155);
|
|
26
|
+
const checksum = header.reduce((sum, byte) => sum + byte, 0); write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8);
|
|
27
|
+
chunks.push(header, fs.readFileSync(filename), Buffer.alloc((512 - stat.size % 512) % 512));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
add(directory); chunks.push(Buffer.alloc(1024)); return Buffer.concat(chunks);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function downloadRemoteMidRecipe(releaseName, { fetchImpl = fetch } = {}) {
|
|
34
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'jsn-mid-recipe-'));
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetchImpl(buildMidRecipeUrl(releaseName), { redirect: 'error', signal: AbortSignal.timeout(300000) });
|
|
37
|
+
if (!response.ok || !response.body) throw new Error(`MID recipe download failed (HTTP ${response.status})`);
|
|
38
|
+
const archive = path.join(directory, 'recipe.zip'), fd = fs.openSync(archive, 'wx', 0o600); let bytes = 0;
|
|
39
|
+
try { for await (const chunk of response.body) { bytes += chunk.length; if (bytes > LIMIT) throw new Error('MID recipe download exceeds 64 MiB'); fs.writeSync(fd, chunk); } } finally { fs.closeSync(fd); }
|
|
40
|
+
const entries = inspectMidZip(archive, { allowedRoots: null });
|
|
41
|
+
if (entries.reduce((sum, entry) => sum + entry.uncompressedSize, 0) > LIMIT) throw new Error('Expanded MID recipe exceeds 64 MiB');
|
|
42
|
+
const recipe = path.join(directory, 'recipe'); await extractMidZip(archive, recipe, entries);
|
|
43
|
+
// Distribution archives can wrap the Dockerfile in one enclosing directory.
|
|
44
|
+
const candidates = [];
|
|
45
|
+
function find(folder) { for (const name of fs.readdirSync(folder)) { const file = path.join(folder, name); if (fs.statSync(file).isDirectory()) find(file); else if (name === 'Dockerfile') candidates.push(path.dirname(file)); } }
|
|
46
|
+
find(recipe); if (candidates.length !== 1) throw new Error('Official recipe must contain exactly one Dockerfile');
|
|
47
|
+
return recipeTar(candidates[0]);
|
|
48
|
+
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
|
49
|
+
}
|