@jacebenson/jsn 4.3.5-dev.7 → 4.3.5-dev.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.5-dev.7",
3
+ "version": "4.3.5-dev.9",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -88,14 +88,144 @@ export async function resolveUpdateSetByName(app, name) {
88
88
  throw errUsage(`Multiple update sets named "${name}" (${records.length}): ${scopes}.${scopeNote}\nRun bare "jsn updatesets set" and pick by scope.`);
89
89
  }
90
90
 
91
+
92
+ function rawReferenceId(value) {
93
+ if (!value) return '';
94
+ if (typeof value === 'object') return String(value.value || value.sys_id || '');
95
+ return String(value);
96
+ }
97
+
98
+ function referenceName(value) {
99
+ if (!value) return '';
100
+ if (typeof value === 'object') return String(value.display_value || value.name || value.value || value.sys_id || '');
101
+ return String(value);
102
+ }
103
+
104
+ function updateSetApplication(record) {
105
+ const appName = getStringField(record, 'application') || '';
106
+ const scopeName = getStringField(record, 'application.scope') || '';
107
+ return appName && scopeName ? `${appName}/${scopeName}` : (appName || scopeName || '?');
108
+ }
109
+
110
+ function updateSetHierarchyRow(record, { depth, kind, selected = false, missing = false } = {}) {
111
+ return {
112
+ sys_id: getStringField(record, 'sys_id'),
113
+ name: getStringField(record, 'name') || referenceName(record),
114
+ state: getStringField(record, 'state') || '?',
115
+ application: updateSetApplication(record),
116
+ scope: updateSetApplication(record),
117
+ parent: referenceName(record.parent),
118
+ depth,
119
+ kind,
120
+ selected,
121
+ missing,
122
+ };
123
+ }
124
+
125
+ async function fetchUpdateSetById(app, sysID) {
126
+ if (!sysID) return null;
127
+ const params = new URLSearchParams();
128
+ params.set('sysparm_query', `sys_id=${sysID}`);
129
+ params.set('sysparm_limit', '1');
130
+ params.set('sysparm_display_value', 'all');
131
+ params.set('sysparm_fields', 'sys_id,name,state,application,application.scope,parent,description,sys_created_on,sys_updated_on');
132
+ const records = await app.sdk.list('sys_update_set', params);
133
+ return Array.isArray(records) && records.length > 0 ? records[0] : null;
134
+ }
135
+
136
+ /** Build a safe, flat preorder hierarchy with ancestors before the selected set. */
137
+ export async function buildUpdateSetHierarchy(app, selectedRecord) {
138
+ const selectedID = getStringField(selectedRecord, 'sys_id');
139
+ const visited = new Set(selectedID ? [selectedID] : []);
140
+ const cycleIds = new Set();
141
+ const missingParents = [];
142
+ const ancestors = [];
143
+ let parentRef = selectedRecord?.parent;
144
+
145
+ while (rawReferenceId(parentRef)) {
146
+ const parentID = rawReferenceId(parentRef);
147
+ const parentDisplay = referenceName(parentRef);
148
+ if (visited.has(parentID)) {
149
+ cycleIds.add(parentID);
150
+ break;
151
+ }
152
+ visited.add(parentID);
153
+ let parent;
154
+ try { parent = await fetchUpdateSetById(app, parentID); } catch { parent = null; }
155
+ if (!parent) {
156
+ missingParents.push(parentID);
157
+ ancestors.push({ sys_id: parentID, name: parentDisplay || `[missing parent ${parentID}]`, parent: null, missing: true });
158
+ break;
159
+ }
160
+ ancestors.push(parent);
161
+ parentRef = parent.parent;
162
+ }
163
+
164
+ const descendants = [];
165
+ async function walkChildren(parentID, depth) {
166
+ if (!parentID) return;
167
+ let children;
168
+ try {
169
+ const params = new URLSearchParams();
170
+ params.set('sysparm_query', `parent=${parentID}^ORDERBYname`);
171
+ params.set('sysparm_limit', '500');
172
+ params.set('sysparm_display_value', 'all');
173
+ params.set('sysparm_fields', 'sys_id,name,state,application,application.scope,parent,description,sys_created_on,sys_updated_on');
174
+ children = await app.sdk.list('sys_update_set', params);
175
+ } catch {
176
+ children = [];
177
+ }
178
+ if (!Array.isArray(children)) return;
179
+ for (const child of children) {
180
+ const childID = getStringField(child, 'sys_id');
181
+ if (!childID) continue;
182
+ if (visited.has(childID)) {
183
+ cycleIds.add(childID);
184
+ continue;
185
+ }
186
+ visited.add(childID);
187
+ descendants.push({ record: child, depth });
188
+ await walkChildren(childID, depth + 1);
189
+ }
190
+ }
191
+
192
+ const hierarchy = [
193
+ ...ancestors.reverse().map((record, index) => updateSetHierarchyRow(record, { depth: index, kind: 'ancestor', missing: record.missing })),
194
+ updateSetHierarchyRow(selectedRecord, { depth: ancestors.length, kind: 'selected', selected: true }),
195
+ ];
196
+ await walkChildren(selectedID, ancestors.length + 1);
197
+ hierarchy.push(...descendants.map(({ record, depth }) => updateSetHierarchyRow(record, { depth, kind: 'descendant' })));
198
+
199
+ return { hierarchy, cycleIds: [...cycleIds], missingParents };
200
+ }
201
+
202
+ function renderUpdateSetHierarchy(rows, cycleIds = [], missingParents = []) {
203
+ const lines = ['Update set hierarchy:'];
204
+ const hasLaterSibling = (index, depth) => {
205
+ for (let i = index + 1; i < rows.length; i += 1) {
206
+ if (rows[i].depth < depth) return false;
207
+ if (rows[i].depth === depth) return true;
208
+ }
209
+ return false;
210
+ };
211
+ rows.forEach((row, index) => {
212
+ const continuation = [];
213
+ for (let level = 0; level < row.depth - 1; level += 1) {
214
+ continuation.push(hasLaterSibling(index, level) ? '│ ' : ' ');
215
+ }
216
+ const connector = row.depth > 0 ? (hasLaterSibling(index, row.depth) ? '├── ' : '└── ') : '';
217
+ const marker = row.selected ? '◉' : '○';
218
+ const missing = row.missing ? ' [missing parent]' : '';
219
+ lines.push(`${continuation.join('')}${connector}${marker} ${row.name}${missing} [${row.state}] (${row.scope})`);
220
+ });
221
+ if (missingParents.length > 0) lines.push(` ⚠️ Missing parent records: ${missingParents.join(', ')}`);
222
+ if (cycleIds.length > 0) lines.push(` ⚠️ Hierarchy cycle detected; repeated branch omitted (${cycleIds.join(', ')})`);
223
+ return lines;
224
+ }
225
+
91
226
  /**
92
- * Rich display for an update set: header fields + child update filenames.
93
- * Children (sys_update_xml) listed filename-only, sorted by sys_updated_on
94
- * descending (newest first). Shared by `list` pick and `show`.
95
- *
96
- * Fetches an enriched record itself (sysparm_display_value=all so the parent
97
- * reference renders as a name, not a sys_id) — callers can pass a thin
98
- * picker record; the formatter completes it.
227
+ * Rich display for an update set: header fields, update-set hierarchy, and
228
+ * separate child update payload details.
99
229
  */
100
230
  export async function formatUpdateSetDetail(app, record) {
101
231
  const sysID = getStringField(record, 'sys_id');
@@ -109,7 +239,7 @@ export async function formatUpdateSetDetail(app, record) {
109
239
  params.set('sysparm_query', `sys_id=${sysID}`);
110
240
  params.set('sysparm_limit', '1');
111
241
  params.set('sysparm_display_value', 'all');
112
- params.set('sysparm_fields', 'sys_id,name,state,application,application.scope,parent,sys_created_on,sys_updated_on');
242
+ params.set('sysparm_fields', 'sys_id,name,state,application,application.scope,parent,description,sys_created_on,sys_updated_on');
113
243
  const recs = await app.sdk.list('sys_update_set', params);
114
244
  if (Array.isArray(recs) && recs.length > 0) full = recs[0];
115
245
  } catch {
@@ -123,6 +253,7 @@ export async function formatUpdateSetDetail(app, record) {
123
253
  const parent = getStringField(full, 'parent') || '';
124
254
  const created = getStringField(full, 'sys_created_on') || '';
125
255
  const updated = getStringField(full, 'sys_updated_on') || '';
256
+ const description = getStringField(full, 'description');
126
257
 
127
258
  // Child updates: filename only, newest first by sys_updated_on.
128
259
  // Also group by element type (sys_class_name) and flag risky types for
@@ -170,6 +301,7 @@ export async function formatUpdateSetDetail(app, record) {
170
301
  // children are best-effort — a broken query shouldn't kill the display
171
302
  }
172
303
 
304
+ const hierarchyData = await buildUpdateSetHierarchy(app, full);
173
305
  const lines = [];
174
306
  lines.push(`Update set: ${name}`);
175
307
  lines.push(` State: ${state}`);
@@ -177,7 +309,10 @@ export async function formatUpdateSetDetail(app, record) {
177
309
  if (parent) lines.push(` Parent: ${parent}`);
178
310
  if (created) lines.push(` Created: ${created}`);
179
311
  if (updated) lines.push(` Updated: ${updated}`);
180
- lines.push(`Updates: ${children.length}`);
312
+ if (description) lines.push(` Description: ${description}`);
313
+ lines.push(...renderUpdateSetHierarchy(hierarchyData.hierarchy, hierarchyData.cycleIds, hierarchyData.missingParents));
314
+ lines.push('Payload / update XML:');
315
+ lines.push(` Updates: ${children.length}`);
181
316
  const typeEntries = Object.entries(byType).sort((a, b) => b[1] - a[1]);
182
317
  if (typeEntries.length > 0) {
183
318
  lines.push(` By type: ${typeEntries.map(([t, n]) => `${t}×${n}`).join(', ')}`);
@@ -243,6 +378,11 @@ export async function formatUpdateSetDetail(app, record) {
243
378
  parent,
244
379
  sys_created_on: created,
245
380
  sys_updated_on: updated,
381
+ description,
382
+ scope: application,
383
+ hierarchy: hierarchyData.hierarchy,
384
+ hierarchy_cycles: hierarchyData.cycleIds,
385
+ hierarchy_missing_parents: hierarchyData.missingParents,
246
386
  children,
247
387
  by_type: byType,
248
388
  risky_count: risky.length,
package/src/config.js CHANGED
@@ -29,11 +29,17 @@ export function cacheDir() {
29
29
  export function normalizeInstanceURL(url) {
30
30
  if (!url) return '';
31
31
  url = url.trim();
32
- url = url.replace(/\/$/, '');
33
32
  if (!/^https?:\/\//i.test(url)) {
34
33
  url = 'https://' + url;
35
34
  }
36
- return url;
35
+ try {
36
+ // Profiles store the instance origin, never a page, API route, query, or
37
+ // fragment pasted from the browser.
38
+ return new URL(url).origin;
39
+ } catch {
40
+ // Preserve the old permissive behavior for non-standard local endpoints.
41
+ return url.replace(/\/$/, '');
42
+ }
37
43
  }
38
44
 
39
45
  function loadFromFile(cfg, filePath, source) {