@jacebenson/jsn 4.0.3 → 4.0.4

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.
@@ -1,4 +1,66 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
2
+ import { requireCurrentUserSysId, setCurrentApplication } from '../../context.js';
3
+
4
+ /** Format a picker label for a scope: "Name [scope]". */
5
+ export function formatScopeLabel(r) {
6
+ return `${getStringField(r, 'name')} [${getStringField(r, 'scope') || '?'}]`;
7
+ }
8
+
9
+ /**
10
+ * Rich display for a scope: header fields + record link.
11
+ * Shared by `list` pick and `show` so they can't drift.
12
+ */
13
+ export async function formatScopeDetail(app, record) {
14
+ const sysID = getStringField(record, 'sys_id');
15
+ const instance = app.getEffectiveInstance();
16
+ const link = `${instance}/sys_scope.do?sys_id=${sysID}`;
17
+
18
+ let full = record;
19
+ try {
20
+ const params = new URLSearchParams();
21
+ params.set('sysparm_query', `sys_id=${sysID}`);
22
+ params.set('sysparm_limit', '1');
23
+ params.set('sysparm_display_value', 'all');
24
+ params.set('sysparm_fields', 'sys_id,name,scope,short_description,active,description,version,release_date,sys_class_name,sys_created_on,sys_updated_on');
25
+ const recs = await app.sdk.list('sys_scope', params);
26
+ if (Array.isArray(recs) && recs.length > 0) full = recs[0];
27
+ } catch {
28
+ // fall back to the passed record
29
+ }
30
+
31
+ // Store apps carry sys_class_name "Store Application" — link to the
32
+ // Store search for the scope value (no store-side app ID is available
33
+ // locally; the search URL is the reliable path).
34
+ const isStoreApp = String(getStringField(full, 'sys_class_name')).toLowerCase().includes('store');
35
+ const storeLink = isStoreApp ? `https://store.servicenow.com/store/apps?q=${encodeURIComponent(getStringField(full, 'scope'))}` : '';
36
+
37
+ const lines = [];
38
+ lines.push(`Scope: ${getStringField(full, 'name') || '?'}`);
39
+ lines.push(` Scope value: ${getStringField(full, 'scope') || '?'}`);
40
+ if (getStringField(full, 'version')) lines.push(` Version: ${getStringField(full, 'version')}`);
41
+ if (getStringField(full, 'release_date')) lines.push(` Released: ${getStringField(full, 'release_date')}`);
42
+ if (getStringField(full, 'short_description')) lines.push(` Description: ${getStringField(full, 'short_description')}`);
43
+ if (getStringField(full, 'active')) lines.push(` Active: ${getStringField(full, 'active')}`);
44
+ if (storeLink) lines.push(` Store: ${storeLink}`);
45
+ if (getStringField(full, 'sys_created_on')) lines.push(` Created: ${getStringField(full, 'sys_created_on')}`);
46
+ if (getStringField(full, 'sys_updated_on')) lines.push(` Updated: ${getStringField(full, 'sys_updated_on')}`);
47
+ lines.push(` Link: ${link}`);
48
+
49
+ return {
50
+ sys_id: sysID,
51
+ name: getStringField(full, 'name'),
52
+ scope: getStringField(full, 'scope'),
53
+ version: getStringField(full, 'version') || '',
54
+ release_date: getStringField(full, 'release_date') || '',
55
+ short_description: getStringField(full, 'short_description') || '',
56
+ active: getStringField(full, 'active') || '',
57
+ store_link: storeLink,
58
+ sys_created_on: getStringField(full, 'sys_created_on') || '',
59
+ sys_updated_on: getStringField(full, 'sys_updated_on') || '',
60
+ link,
61
+ _formatted: lines.join('\n'),
62
+ };
63
+ }
2
64
 
3
65
  export function scopesCmd(wrap) {
4
66
  return {
@@ -21,12 +83,12 @@ export function scopesCmd(wrap) {
21
83
 
22
84
  const picked = await interactiveList({
23
85
  app, table: 'sys_scope', singular: 'scope', columns, limit: argv.limit, query, labelField: 'name',
24
- formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'scope') || '?'}]`,
86
+ formatLabel: formatScopeLabel,
25
87
  });
26
88
  if (picked === undefined) return; // user cancelled
27
89
  if (picked) {
28
- picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_scope' };
29
- return app.ok(picked, { summary: `Scope: ${getStringField(picked, 'name')}` });
90
+ const detail = await formatScopeDetail(app, picked);
91
+ return app.ok(detail, { summary: `Scope: ${getStringField(picked, 'name')}` });
30
92
  }
31
93
 
32
94
  const params = new URLSearchParams();
@@ -55,6 +117,7 @@ export function scopesCmd(wrap) {
55
117
  type: 'string',
56
118
  }),
57
119
  handler: wrap(async (argv, app) => {
120
+ assertSafeExactMatch(argv.scope);
58
121
  let records;
59
122
  // Try by scope value first, then by name
60
123
  for (const queryField of ['scope', 'name']) {
@@ -68,47 +131,36 @@ export function scopesCmd(wrap) {
68
131
  if (!records || records.length === 0) {
69
132
  throw new Error(`Scope not found: ${argv.scope}`);
70
133
  }
71
- app.ok(records[0], { summary: `Scope ${argv.scope}` });
134
+ const detail = await formatScopeDetail(app, records[0]);
135
+ app.ok(detail, { summary: `Scope ${argv.scope}` });
72
136
  }),
73
137
  })
74
138
  .command({
75
- command: 'set <scope>',
76
- describe: 'Set the current application scope',
139
+ command: 'set [scope]',
140
+ describe: 'Set the current application scope (interactive picker when run bare)',
77
141
  handler: wrap(async (argv, app) => {
142
+ let scopeArg = argv.scope;
143
+ if (!scopeArg) {
144
+ const picked = await interactiveList({
145
+ app, table: 'sys_scope', singular: 'scope', columns: ['name', 'scope'], labelField: 'name',
146
+ formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'scope') || '?'}]`,
147
+ });
148
+ if (!picked) return; // cancelled or non-interactive
149
+ scopeArg = getStringField(picked, 'scope');
150
+ }
151
+ assertSafeExactMatch(scopeArg);
78
152
  const params = new URLSearchParams();
79
- params.set('sysparm_query', `scope=${argv.scope}`);
153
+ params.set('sysparm_query', `scope=${scopeArg}`);
80
154
  params.set('sysparm_limit', '1');
81
- params.set('sysparm_fields', 'sys_id,scope');
155
+ params.set('sysparm_fields', 'sys_id,scope,name');
82
156
  const records = await app.sdk.list('sys_scope', params);
83
157
  if (records.length === 0) {
84
- throw new Error(`Scope not found: ${argv.scope}`);
158
+ throw new Error(`Scope not found: ${scopeArg}`);
85
159
  }
86
160
  const scopeSysID = getStringField(records[0], 'sys_id');
87
- // Update user preference
88
- const user = await app.sdk.list('sys_user', new URLSearchParams({
89
- sysparm_query: 'user_name=javascript:gs.getUserName()',
90
- sysparm_limit: '1',
91
- sysparm_fields: 'sys_id',
92
- }));
93
- if (user.length === 0) {
94
- throw new Error('Could not determine current user');
95
- }
96
- const userSysID = getStringField(user[0], 'sys_id');
97
- const prefParams = new URLSearchParams();
98
- prefParams.set('sysparm_query', `user=${userSysID}^name=apps.current_app`);
99
- prefParams.set('sysparm_limit', '1');
100
- const prefs = await app.sdk.list('sys_user_preference', prefParams);
101
- if (prefs.length > 0) {
102
- await app.sdk.update('sys_user_preference', getStringField(prefs[0], 'sys_id'), { value: scopeSysID });
103
- } else {
104
- await app.sdk.create('sys_user_preference', {
105
- user: userSysID,
106
- name: 'apps.current_app',
107
- value: scopeSysID,
108
- type: 'string',
109
- });
110
- }
111
- app.ok({ scope: argv.scope, sys_id: scopeSysID }, { summary: `Current scope: ${argv.scope}` });
161
+ const userSysID = await requireCurrentUserSysId(app.sdk);
162
+ await setCurrentApplication(app.sdk, userSysID, scopeSysID);
163
+ app.ok({ scope: scopeArg, sys_id: scopeSysID }, { summary: `Current scope: ${scopeArg}` });
112
164
  }),
113
165
  })
114
166
  .command({
@@ -158,7 +210,7 @@ export function scopesCmd(wrap) {
158
210
  console.log('Commands:');
159
211
  console.log(' list List application scopes');
160
212
  console.log(' show <scope> Show a scope');
161
- console.log(' set <scope> Set the current application scope');
213
+ console.log(' set [scope] Set the current application scope (picker when run bare)');
162
214
  console.log(' create Create a new application scope');
163
215
  console.log('\nRun "jsn scopes <command> --help" for details.');
164
216
  }
@@ -1,5 +1,184 @@
1
- import fs from 'node:fs';
2
- import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
2
+ import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../../context.js';
3
+ import { errUsage } from '../../errors.js';
4
+
5
+ /**
6
+ * Scope-mismatch check for an update set vs the current app.
7
+ * The update set's `application` is a sys_app reference — its display is the
8
+ * app NAME ("test"), while the current app resolves to the scope VALUE
9
+ * ("x_8821_test"). Compare by app sys_id: same sys_id = same scope.
10
+ * @param {object} updateSetApp raw application field ({display_value, value} or string)
11
+ * @param {object} currentApp { scope, appSysId } from getCurrentApplication
12
+ * @returns {string} warning text, or '' when scopes match / can't determine
13
+ */
14
+ export function scopeMismatchWarning(updateSetApp, currentApp) {
15
+ if (!updateSetApp || !currentApp) return '';
16
+ const updateSetAppId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
17
+ if (!updateSetAppId || !currentApp.appSysId || updateSetAppId === currentApp.appSysId) return '';
18
+ const name = updateSetApp?.display_value || updateSetApp;
19
+ return ` ⚠️ update set is in app "${name}", but current app is "${currentApp.scope}" — changes won't land here. Run: jsn scopes set ${name}`;
20
+ }
21
+
22
+ /** Format a picker label for an update set: "Name [State] (app/scope)". */
23
+ export function formatUpdateSetLabel(r) {
24
+ const appName = getStringField(r, 'application');
25
+ const scopeName = getStringField(r, 'application.scope');
26
+ const scope = appName && scopeName ? `${appName}/${scopeName}` : (appName || scopeName);
27
+ return `${getStringField(r, 'name')} [${getStringField(r, 'state') || '?'}] (${scope || '?'})`;
28
+ }
29
+
30
+ /**
31
+ * Resolve an update set by name, disambiguating duplicate names via the
32
+ * current application scope. Every scope has its own "Default" update set,
33
+ * so a bare name lookup must prefer the set in the current scope and only
34
+ * error when it's still ambiguous.
35
+ * @returns {object} resolved record ({sys_id,name,application,state,...})
36
+ */
37
+ export async function resolveUpdateSetByName(app, name) {
38
+ assertSafeExactMatch(name);
39
+ const params = new URLSearchParams();
40
+ params.set('sysparm_query', `name=${name}`);
41
+ params.set('sysparm_limit', '50');
42
+ params.set('sysparm_display_value', 'all');
43
+ params.set('sysparm_fields', 'sys_id,name,application,state');
44
+ const records = await app.sdk.list('sys_update_set', params);
45
+ if (records.length === 0) {
46
+ throw new Error(`Update set not found: ${name}`);
47
+ }
48
+ if (records.length === 1) return records[0];
49
+
50
+ // Multiple sets share the name — prefer the one in the current scope.
51
+ try {
52
+ const userSysID = await requireCurrentUserSysId(app.sdk);
53
+ const currentApp = await getCurrentApplication(app.sdk, userSysID);
54
+ const appIdOf = (r) => r.application?.value || (typeof r.application === 'string' ? r.application : '');
55
+ const inScope = records.find((r) => {
56
+ const appId = appIdOf(r);
57
+ return appId && currentApp.appSysId && appId === currentApp.appSysId;
58
+ });
59
+ if (inScope) return inScope;
60
+ } catch {
61
+ // fall through to ambiguity error
62
+ }
63
+
64
+ const scopes = records.map((r) => r.application?.display_value || r.application || '?').join(', ');
65
+ throw errUsage(`Multiple update sets named "${name}" (${records.length}): ${scopes}.\nRun bare "jsn updatesets set" and pick by scope.`);
66
+ }
67
+
68
+ /**
69
+ * Rich display for an update set: header fields + child update filenames.
70
+ * Children (sys_update_xml) listed filename-only, sorted by sys_updated_on
71
+ * descending (newest first). Shared by `list` pick and `show`.
72
+ *
73
+ * Fetches an enriched record itself (sysparm_display_value=all so the parent
74
+ * reference renders as a name, not a sys_id) — callers can pass a thin
75
+ * picker record; the formatter completes it.
76
+ */
77
+ export async function formatUpdateSetDetail(app, record) {
78
+ const sysID = getStringField(record, 'sys_id');
79
+ const name = getStringField(record, 'name');
80
+ const instance = app.getEffectiveInstance();
81
+ const link = `${instance}/sys_update_set.do?sys_id=${sysID}`;
82
+
83
+ let full = record;
84
+ try {
85
+ const params = new URLSearchParams();
86
+ params.set('sysparm_query', `sys_id=${sysID}`);
87
+ params.set('sysparm_limit', '1');
88
+ params.set('sysparm_display_value', 'all');
89
+ params.set('sysparm_fields', 'sys_id,name,state,application,application.scope,parent,sys_created_on,sys_updated_on');
90
+ const recs = await app.sdk.list('sys_update_set', params);
91
+ if (Array.isArray(recs) && recs.length > 0) full = recs[0];
92
+ } catch {
93
+ // fall back to the passed record
94
+ }
95
+
96
+ const state = getStringField(full, 'state') || '?';
97
+ const appName = getStringField(full, 'application') || '';
98
+ const scopeName = getStringField(full, 'application.scope') || '';
99
+ const application = appName && scopeName ? `${appName}/${scopeName}` : (appName || scopeName);
100
+ const parent = getStringField(full, 'parent') || '';
101
+ const created = getStringField(full, 'sys_created_on') || '';
102
+ const updated = getStringField(full, 'sys_updated_on') || '';
103
+
104
+ // Child updates: filename only, newest first by sys_updated_on
105
+ let children = [];
106
+ try {
107
+ const params = new URLSearchParams();
108
+ params.set('sysparm_query', `update_set=${sysID}^ORDERBYDESCsys_updated_on`);
109
+ params.set('sysparm_limit', '500');
110
+ params.set('sysparm_fields', 'name,sys_updated_on');
111
+ const updates = await app.sdk.list('sys_update_xml', params);
112
+ if (Array.isArray(updates)) {
113
+ children = updates.map(u => getStringField(u, 'name')).filter(Boolean);
114
+ }
115
+ } catch {
116
+ // children are best-effort — a broken query shouldn't kill the display
117
+ }
118
+
119
+ const lines = [];
120
+ lines.push(`Update set: ${name}`);
121
+ lines.push(` State: ${state}`);
122
+ if (application) lines.push(` Scope: ${application}`);
123
+ if (parent) lines.push(` Parent: ${parent}`);
124
+ if (created) lines.push(` Created: ${created}`);
125
+ if (updated) lines.push(` Updated: ${updated}`);
126
+ lines.push(` Updates: ${children.length}`);
127
+ for (const c of children) {
128
+ lines.push(` ${c}`);
129
+ }
130
+ lines.push(` Link: ${link}`);
131
+
132
+ // State-aware action hints (rendered as breadcrumbs under the detail).
133
+ // Closed sets (complete/committed) cannot be set as current.
134
+ const stateRaw = String(full.state?.value ?? full.state ?? '').toLowerCase();
135
+ const isClosed = ['complete', 'committed'].includes(stateRaw);
136
+ const hints = [];
137
+ if (isClosed) {
138
+ hints.push({
139
+ action: 'set',
140
+ cmd: '',
141
+ description: 'Cannot set as current — update set is closed (complete/committed)',
142
+ });
143
+ } else {
144
+ hints.push({
145
+ action: 'set',
146
+ cmd: `jsn updatesets set "${name}"`,
147
+ description: 'Set as current update set',
148
+ });
149
+ }
150
+ hints.push({
151
+ action: 'parent',
152
+ cmd: `jsn updatesets parent "${name}" --parent "<parent set name>"`,
153
+ description: 'Set the parent of this update set',
154
+ });
155
+ if (!isClosed) {
156
+ hints.push({
157
+ action: 'complete',
158
+ cmd: 'jsn updatesets complete',
159
+ description: 'Mark the current update set as complete (set it first with jsn updatesets set)',
160
+ });
161
+ hints.push({
162
+ action: 'ignore',
163
+ cmd: 'jsn updatesets ignore',
164
+ description: 'Ignore the current update set (won\'t be installed)',
165
+ });
166
+ }
167
+
168
+ return {
169
+ sys_id: sysID,
170
+ name,
171
+ state,
172
+ application,
173
+ parent,
174
+ sys_created_on: created,
175
+ sys_updated_on: updated,
176
+ children,
177
+ link,
178
+ _formatted: lines.join('\n'),
179
+ hints,
180
+ };
181
+ }
3
182
 
4
183
  export function updateSetsCmd(wrap) {
5
184
  return {
@@ -11,23 +190,23 @@ export function updateSetsCmd(wrap) {
11
190
  .command({
12
191
  command: 'list',
13
192
  aliases: ['ls'],
14
- describe: 'List update sets',
193
+ describe: 'List update sets (shows the application scope each belongs to)',
15
194
  builder: (y) => y
16
195
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
17
196
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
18
197
  .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
19
198
  handler: wrap(async (argv, app) => {
20
- const columns = argv.columns ? argv.columns.split(',') : ['name', 'state', 'application'];
199
+ const columns = argv.columns ? argv.columns.split(',') : ['name', 'state', 'application', 'application.scope'];
21
200
  const query = argv.query || '';
22
201
 
23
202
  const picked = await interactiveList({
24
203
  app, table: 'sys_update_set', singular: 'update set', columns, limit: argv.limit, query, labelField: 'name',
25
- formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'state') || '?'}]`,
204
+ formatLabel: formatUpdateSetLabel,
26
205
  });
27
206
  if (picked === undefined) return; // user cancelled
28
207
  if (picked) {
29
- picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_update_set' };
30
- return app.ok(picked, { summary: `Update set: ${getStringField(picked, 'name')}` });
208
+ const detail = await formatUpdateSetDetail(app, picked);
209
+ return app.ok(detail, { summary: `Update set: ${getStringField(picked, 'name')}`, breadcrumbs: detail.hints });
31
210
  }
32
211
 
33
212
  const params = new URLSearchParams();
@@ -51,61 +230,102 @@ export function updateSetsCmd(wrap) {
51
230
  aliases: ['get'],
52
231
  describe: 'Show an update set',
53
232
  handler: wrap(async (argv, app) => {
54
- const params = new URLSearchParams();
55
- params.set('sysparm_query', `name=${argv.name}`);
56
- params.set('sysparm_limit', '1');
57
- params.set('sysparm_display_value', 'all');
58
- const records = await app.sdk.list('sys_update_set', params);
59
- if (records.length === 0) {
60
- throw new Error(`Update set not found: ${argv.name}`);
61
- }
62
- app.ok(records[0], { summary: `Update set ${argv.name}` });
233
+ const record = await resolveUpdateSetByName(app, argv.name);
234
+ const detail = await formatUpdateSetDetail(app, record);
235
+ app.ok(detail, { summary: `Update set ${argv.name}`, breadcrumbs: detail.hints });
63
236
  }),
64
237
  })
65
238
  .command({
66
- command: 'set <name>',
67
- describe: 'Set the current update set',
239
+ command: 'set [name]',
240
+ describe: 'Set the current update set (interactive picker with scope when run bare)',
68
241
  handler: wrap(async (argv, app) => {
69
- const params = new URLSearchParams();
70
- params.set('sysparm_query', `name=${argv.name}`);
71
- params.set('sysparm_limit', '1');
72
- params.set('sysparm_fields', 'sys_id,name');
73
- const records = await app.sdk.list('sys_update_set', params);
74
- if (records.length === 0) {
75
- throw new Error(`Update set not found: ${argv.name}`);
242
+ let name = argv.name;
243
+ let sysID = null;
244
+ let updateSetApp = null; // raw application field ({display_value, value} or string)
245
+ let stateRaw = '';
246
+ if (!name) {
247
+ const picked = await interactiveList({
248
+ app, table: 'sys_update_set', singular: 'update set', columns: ['name', 'state', 'application', 'application.scope'], labelField: 'name',
249
+ formatLabel: formatUpdateSetLabel,
250
+ });
251
+ if (!picked) return; // cancelled or non-interactive
252
+ // Picker returns the full record — use its sys_id directly.
253
+ // Do NOT re-query by name: duplicate names (e.g. "Default")
254
+ // would resolve to the wrong record.
255
+ name = getStringField(picked, 'name');
256
+ sysID = getStringField(picked, 'sys_id');
257
+ updateSetApp = picked.application;
258
+ stateRaw = String(picked.state?.value ?? picked.state ?? '').toLowerCase();
76
259
  }
77
- const sysID = getStringField(records[0], 'sys_id');
78
- // Update user preference
79
- const user = await app.sdk.list('sys_user', new URLSearchParams({
80
- sysparm_query: 'user_name=javascript:gs.getUserName()',
81
- sysparm_limit: '1',
82
- sysparm_fields: 'sys_id',
83
- }));
84
- if (user.length === 0) {
85
- throw new Error('Could not determine current user');
260
+ if (!sysID) {
261
+ const resolved = await resolveUpdateSetByName(app, name);
262
+ sysID = getStringField(resolved, 'sys_id');
263
+ updateSetApp = resolved.application;
264
+ stateRaw = String(resolved.state?.value ?? resolved.state ?? '').toLowerCase();
86
265
  }
87
- const userSysID = getStringField(user[0], 'sys_id');
88
- // Find or create preference
89
- const prefParams = new URLSearchParams();
90
- prefParams.set('sysparm_query', `user=${userSysID}^name=sys_update_set`);
91
- prefParams.set('sysparm_limit', '1');
92
- const prefs = await app.sdk.list('sys_user_preference', prefParams);
93
- if (prefs.length > 0) {
94
- await app.sdk.update('sys_user_preference', getStringField(prefs[0], 'sys_id'), { value: sysID });
95
- } else {
96
- await app.sdk.create('sys_user_preference', {
97
- user: userSysID,
98
- name: 'sys_update_set',
99
- value: sysID,
100
- type: 'string',
266
+ if (['complete', 'ignore'].includes(stateRaw)) {
267
+ throw errUsage(`Cannot set "${name}" as current — update set is ${stateRaw === 'ignore' ? 'ignored' : 'complete'}.\nView it instead: jsn updatesets show "${name}"`);
268
+ }
269
+ const userSysID = await requireCurrentUserSysId(app.sdk);
270
+ await setCurrentUpdateSet(app.sdk, userSysID, sysID);
271
+
272
+ // Like the platform update set picker: switching the set also
273
+ // switches the application scope into the set's scope. The
274
+ // application field is a sys_scope reference, so its raw value
275
+ // IS the scope sys_id the apps.current_app preference stores.
276
+ let scopeNote = '';
277
+ try {
278
+ const scopeSysId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
279
+ if (scopeSysId) {
280
+ await setCurrentApplication(app.sdk, userSysID, scopeSysId);
281
+ const scopeName = updateSetApp?.display_value || updateSetApp;
282
+ scopeNote = ` — scope switched to ${scopeName}`;
283
+ }
284
+ } catch {
285
+ // scope switch failed — fall back to a warning
286
+ const currentApp = await getCurrentApplication(app.sdk, userSysID).catch(() => null);
287
+ scopeNote = scopeMismatchWarning(updateSetApp, currentApp);
288
+ }
289
+
290
+ app.ok({ update_set: name, sys_id: sysID }, { summary: `Current update set: ${name}${scopeNote}` });
291
+ }),
292
+ })
293
+ .command({
294
+ command: 'parent <name>',
295
+ describe: 'Set the parent of an update set',
296
+ builder: (y) => y
297
+ .positional('name', { describe: 'Update set name', type: 'string' })
298
+ .option('parent', { type: 'string', describe: 'Parent update set name' }),
299
+ handler: wrap(async (argv, app) => {
300
+ const child = await resolveUpdateSetByName(app, argv.name);
301
+ const sysID = getStringField(child, 'sys_id');
302
+ const childScope = getStringField(child, 'application');
303
+
304
+ let parentName = argv.parent;
305
+ if (!parentName) {
306
+ const picked = await interactiveList({
307
+ app, table: 'sys_update_set', singular: 'parent update set', columns: ['name', 'state', 'application', 'application.scope'], labelField: 'name',
308
+ formatLabel: formatUpdateSetLabel,
101
309
  });
310
+ if (!picked) return; // cancelled or non-interactive
311
+ parentName = getStringField(picked, 'name');
312
+ }
313
+ const parentRecord = await resolveUpdateSetByName(app, parentName);
314
+ const parentSysID = getStringField(parentRecord, 'sys_id');
315
+ const parentScope = getStringField(parentRecord, 'application');
316
+
317
+ let warn = '';
318
+ if (childScope && parentScope && childScope !== parentScope) {
319
+ warn = ` ⚠️ child is in scope "${childScope}", parent is in scope "${parentScope}" — allowed, but they won't commit as one unit`;
102
320
  }
103
- app.ok({ update_set: argv.name, sys_id: sysID }, { summary: `Current update set: ${argv.name}` });
321
+
322
+ await app.sdk.update('sys_update_set', sysID, { parent: parentSysID });
323
+ app.ok({ update_set: argv.name, parent: parentName }, { summary: `Parent of "${argv.name}" is now "${parentName}"${warn}` });
104
324
  }),
105
325
  })
106
326
  .command({
107
327
  command: 'create',
108
- describe: 'Create a new update set',
328
+ describe: 'Create a new update set (auto-sets as current)',
109
329
  builder: (y) => y
110
330
  .option('name', { alias: 'n', type: 'string', demandOption: true, describe: 'Update set name' })
111
331
  .option('description', { type: 'string', describe: 'Description' }),
@@ -119,30 +339,8 @@ export function updateSetsCmd(wrap) {
119
339
  // Auto-set as current update set
120
340
  const sysID = record?.sys_id?.value || record?.sys_id;
121
341
  try {
122
- const user = await app.sdk.list('sys_user', new URLSearchParams({
123
- sysparm_query: 'user_name=javascript:gs.getUserName()',
124
- sysparm_limit: '1',
125
- sysparm_fields: 'sys_id',
126
- }));
127
- if (user.length > 0) {
128
- const userSysID = user[0].sys_id?.value || user[0].sys_id;
129
- // Check for existing preference
130
- const prefParams = new URLSearchParams();
131
- prefParams.set('sysparm_query', `user=${userSysID}^name=sys_update_set`);
132
- prefParams.set('sysparm_limit', '1');
133
- const prefs = await app.sdk.list('sys_user_preference', prefParams);
134
- if (prefs.length > 0) {
135
- const prefID = prefs[0].sys_id?.value || prefs[0].sys_id;
136
- await app.sdk.update('sys_user_preference', prefID, { value: sysID });
137
- } else {
138
- await app.sdk.create('sys_user_preference', {
139
- user: userSysID,
140
- name: 'sys_update_set',
141
- value: sysID,
142
- type: 'string',
143
- });
144
- }
145
- }
342
+ const userSysID = await requireCurrentUserSysId(app.sdk);
343
+ await setCurrentUpdateSet(app.sdk, userSysID, sysID);
146
344
  } catch {
147
345
  // Non-fatal — auto-set is a convenience, not mandatory
148
346
  }
@@ -165,37 +363,29 @@ export function updateSetsCmd(wrap) {
165
363
  }),
166
364
  })
167
365
  .command({
168
- command: 'export <name>',
169
- describe: 'Export an update set to XML',
170
- builder: (y) => y
171
- .positional('name', {
172
- describe: 'Update set name',
173
- type: 'string',
174
- })
175
- .option('output', {
176
- alias: 'o',
177
- type: 'string',
178
- describe: 'Output file path (default: stdout)',
179
- }),
366
+ command: 'complete',
367
+ describe: 'Mark the current update set as complete',
180
368
  handler: wrap(async (argv, app) => {
181
- const params = new URLSearchParams();
182
- params.set('sysparm_query', `name=${argv.name}`);
183
- params.set('sysparm_limit', '1');
184
- params.set('sysparm_fields', 'sys_id,name');
185
- const records = await app.sdk.list('sys_update_set', params);
186
- if (records.length === 0) {
187
- throw new Error(`Update set not found: ${argv.name}`);
369
+ const userSysID = await requireCurrentUserSysId(app.sdk);
370
+ const current = await getCurrentUpdateSet(app.sdk, userSysID);
371
+ if (!current?.sys_id) {
372
+ throw errUsage('No current update set. Set one first:\n jsn updatesets set');
188
373
  }
189
- const sysID = getStringField(records[0], 'sys_id');
190
- const instance = app.getEffectiveInstance();
191
- const url = `${instance}/sys_update_set.do?XML&sysparm_sys_id=${sysID}`;
192
- const xml = await app.sdk.rawRequest(url, { method: 'GET' });
193
- if (argv.output) {
194
- fs.writeFileSync(argv.output, xml, 'utf-8');
195
- app.ok({ name: argv.name, sys_id: sysID, output: argv.output }, { summary: `Exported update set to ${argv.output}` });
196
- } else {
197
- process.stdout.write(xml + '\n');
374
+ await app.sdk.update('sys_update_set', current.sys_id, { state: 'complete' });
375
+ app.ok({ update_set: current.name, state: 'complete' }, { summary: `Update set marked complete: ${current.name}` });
376
+ }),
377
+ })
378
+ .command({
379
+ command: 'ignore',
380
+ describe: 'Ignore the current update set (won\'t be installed)',
381
+ handler: wrap(async (argv, app) => {
382
+ const userSysID = await requireCurrentUserSysId(app.sdk);
383
+ const current = await getCurrentUpdateSet(app.sdk, userSysID);
384
+ if (!current?.sys_id) {
385
+ throw errUsage('No current update set. Set one first:\n jsn updatesets set');
198
386
  }
387
+ await app.sdk.update('sys_update_set', current.sys_id, { state: 'ignore' });
388
+ app.ok({ update_set: current.name, state: 'ignore' }, { summary: `Update set ignored: ${current.name}` });
199
389
  }),
200
390
  })
201
391
 
@@ -204,12 +394,13 @@ export function updateSetsCmd(wrap) {
204
394
  if (!argv._[1]) {
205
395
  console.log('Manage ServiceNow update sets.\n');
206
396
  console.log('Commands:');
207
- console.log(' list List update sets');
397
+ console.log(' list List update sets (shows scope)');
208
398
  console.log(' show <name> Show an update set');
209
- console.log(' set <name> Set the current update set');
399
+ console.log(' set [name] Set the current update set (picker when run bare)');
210
400
  console.log(' create Create a new update set (auto-sets as current)');
211
- console.log(' export <name> Export an update set to XML');
212
- console.log(' complete <name> Mark an update set as complete (coming soon)');
401
+ console.log(' complete Mark the current update set as complete');
402
+ console.log(' ignore Ignore the current update set');
403
+ console.log(' parent <name> Set the parent of an update set');
213
404
  console.log('\nRun "jsn updatesets <command> --help" for details.');
214
405
  console.log('\nTip: Create an update set first:');
215
406
  console.log(' jsn updatesets create --name "My Feature"');