@jacebenson/jsn 4.0.2 → 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,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"');
@@ -15,6 +15,7 @@ import { formsCmd } from './dev/forms.js';
15
15
  import { listsCmd } from './dev/lists.js';
16
16
  import { updateSetsCmd } from './dev/updatesets.js';
17
17
  import { scopesCmd } from './dev/scopes.js';
18
+ import { domainsCmd } from './dev/domains.js';
18
19
  import { evalCmd } from './dev/eval.js';
19
20
  import { restCmd } from './dev/rest.js';
20
21
  import { logsCmd } from './dev/logs.js';
@@ -47,6 +48,7 @@ export function devCmd(wrap) {
47
48
  .command(rolesCmd(wrap))
48
49
  .command(updateSetsCmd(wrap))
49
50
  .command(scopesCmd(wrap))
51
+ .command(domainsCmd(wrap))
50
52
  .command(propertiesCmd(wrap))
51
53
  .command(relationshipsCmd(wrap))
52
54
  .command(appmodulesCmd(wrap))
@@ -61,6 +63,19 @@ export function devCmd(wrap) {
61
63
  .command(evalCmd(wrap))
62
64
  .command(restCmd(wrap));
63
65
  },
64
- handler: () => {},
66
+ handler: (argv) => {
67
+ if (argv._[1]) return; // a subcommand ran — its own handler handled it
68
+ console.log('Manage ServiceNow development artifacts.');
69
+ console.log('');
70
+ console.log('Most dev commands are also available at the top level:');
71
+ console.log(' flows, actions, rules, clientscripts, uiactions, uipolicies,');
72
+ console.log(' tables, columns, forms, lists, import, sppages, spwidgets,');
73
+ console.log(' uipages, appmenu, scrapi, acls, roles, properties,');
74
+ console.log(' relationships, appmodules, listcontrols, views, privileges,');
75
+ console.log(' uxscripts, aliases, catalogscripts, cataloguipolicies, logs,');
76
+ console.log(' updatesets, scopes, eval, rest');
77
+ console.log('');
78
+ console.log('Run "jsn dev <command> --help" or "jsn <command> --help" for details.');
79
+ },
65
80
  };
66
81
  }
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete } from '../helpers.js';
1
+ import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch } from '../helpers.js';
2
2
 
3
3
  const tableDefaultColumns = {
4
4
  incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
@@ -21,7 +21,7 @@ function getDefaultColumns(table) {
21
21
 
22
22
  export function recordsCmd(wrap) {
23
23
  return {
24
- command: 'records <subcommand>',
24
+ command: 'records [subcommand]',
25
25
  describe: 'Query and manage records in any table (e.g. "records list --table incident --query priority=1")',
26
26
  builder: (yargs) => {
27
27
  return yargs
@@ -208,7 +208,8 @@ export function recordsCmd(wrap) {
208
208
  })
209
209
 
210
210
  },
211
- handler: () => {
211
+ handler: (argv) => {
212
+ if (argv._[1]) return; // a subcommand ran — its own handler handled it
212
213
  console.log('Query and manage records in any ServiceNow table.');
213
214
  console.log('');
214
215
  console.log('Available subcommands:');
@@ -1,25 +1,61 @@
1
- import { loginWizard } from './auth.js';
1
+ import { loginWizard, modifyProfile, removeProfile, pickProfile } from './auth.js';
2
+ import { setActiveProfile } from '../config.js';
3
+ import { errUsage } from '../errors.js';
2
4
 
3
- // `jsn setup` is kept as a hidden compatibility alias for `jsn auth login`.
4
- // The wizard lives in auth.js (loginWizard) so there is exactly ONE code path
5
- // for first-run onboarding — this command can never drift out of sync.
5
+ /** Interactive hub: what do you want to do? Returns 'add' | 'switch' | 'remove' | 'modify'. */
6
+ export async function authHubMenu() {
7
+ const { select } = await import('@inquirer/prompts');
8
+ return select({
9
+ message: 'What would you like to do?',
10
+ choices: [
11
+ { name: 'Add a new instance', value: 'add' },
12
+ { name: 'Switch to a different instance', value: 'switch' },
13
+ { name: 'Remove an instance', value: 'remove' },
14
+ { name: 'Modify an instance', value: 'modify' },
15
+ ],
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Dispatch a hub action to the shared operation. Kept separate from the
21
+ * handler so routing is testable without invoking inquirer prompts.
22
+ */
23
+ export async function dispatchSetupAction(app, argv, action) {
24
+ if (action === 'add') {
25
+ const result = await loginWizard(app, argv);
26
+ app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
27
+ } else if (action === 'switch') {
28
+ const name = await pickProfile(app, 'Switch to which instance?');
29
+ await setActiveProfile(app.config, name);
30
+ app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
31
+ } else if (action === 'remove') {
32
+ const name = await pickProfile(app, 'Remove which instance?');
33
+ await removeProfile(app, name);
34
+ } else { // modify
35
+ await modifyProfile(app, argv);
36
+ }
37
+ }
38
+
39
+ // `jsn setup` is the interactive front door for managing instances.
40
+ // The granular, scriptable surface lives under `jsn auth` — this command
41
+ // dispatches to the SAME functions (loginWizard, setActiveProfile,
42
+ // removeProfile, modifyProfile) so the two surfaces can never drift.
6
43
  export function setupCmd(wrap) {
7
44
  return {
8
45
  command: 'setup',
9
- describe: 'Interactive first-time setup (use "jsn auth login")',
10
- hidden: true, // legacy alias for `jsn auth login` — works but hidden from help
11
- builder: (yargs) => {
12
- return yargs
13
- .option('read-only', {
14
- describe: 'Mark profile as read-only (blocks mutation commands)',
15
- type: 'boolean',
16
- default: false,
17
- });
18
- },
46
+ describe: 'Set up and manage your ServiceNow instances (interactive)',
19
47
  handler: wrap(async (argv, app) => {
20
- console.log('Note: "jsn setup" is now "jsn auth login". Running the same wizard...\n');
21
- const result = await loginWizard(app, argv);
22
- app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
48
+ if (!app.isInteractive()) {
49
+ throw errUsage('"jsn setup" is interactive. Use the specific commands:\n jsn auth login <instance>\n jsn auth refresh\n jsn auth status');
50
+ }
51
+ const profileNames = Object.keys(app.config.profiles || {});
52
+ if (profileNames.length === 0) {
53
+ const result = await loginWizard(app, argv);
54
+ app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
55
+ return;
56
+ }
57
+ const action = await authHubMenu();
58
+ await dispatchSetupAction(app, argv, action);
23
59
  }),
24
60
  };
25
61
  }