@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.
- package/package.json +1 -1
- package/src/app.js +25 -3
- package/src/cli.js +3 -1
- package/src/commands/auth.js +116 -7
- package/src/commands/dev/domains.js +292 -0
- package/src/commands/dev/scopes.js +88 -36
- package/src/commands/dev/updatesets.js +297 -106
- package/src/commands/dev.js +16 -1
- package/src/commands/records.js +3 -2
- package/src/context.js +52 -5
- package/src/help.js +16 -2
- package/src/mutations.js +10 -1
- package/src/sdk.js +4 -0
|
@@ -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:
|
|
86
|
+
formatLabel: formatScopeLabel,
|
|
25
87
|
});
|
|
26
88
|
if (picked === undefined) return; // user cancelled
|
|
27
89
|
if (picked) {
|
|
28
|
-
|
|
29
|
-
return app.ok(
|
|
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
|
|
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
|
|
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=${
|
|
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: ${
|
|
158
|
+
throw new Error(`Scope not found: ${scopeArg}`);
|
|
85
159
|
}
|
|
86
160
|
const scopeSysID = getStringField(records[0], 'sys_id');
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
2
|
-
import {
|
|
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:
|
|
204
|
+
formatLabel: formatUpdateSetLabel,
|
|
26
205
|
});
|
|
27
206
|
if (picked === undefined) return; // user cancelled
|
|
28
207
|
if (picked) {
|
|
29
|
-
|
|
30
|
-
return app.ok(
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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: '
|
|
169
|
-
describe: '
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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('
|
|
212
|
-
console.log('
|
|
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"');
|