@jacebenson/jsn 0.0.10 → 0.1.1

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,14 +1,33 @@
1
1
  // Generic dev subcommand builder for table-based CRUD
2
2
 
3
- import { formatRecordForDisplay, getStringField, isHexString } from '../../helpers.js';
3
+ import { formatRecordForDisplay, getStringField, isHexString, parseDataArg } from '../../helpers.js';
4
4
  import { getCurrentUser, getCurrentApplication } from '../../context.js';
5
5
  import readline from 'node:readline';
6
+ import search from '@inquirer/search';
7
+ import { isTTY, FormatAuto } from '../../output.js';
6
8
 
7
9
  function vowelArticle(word) {
8
10
  const first = word.charAt(0).toLowerCase();
9
11
  return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
10
12
  }
11
13
 
14
+ function buildHints(name, singular, readOnly) {
15
+ const crumbs = [];
16
+ if (!readOnly) {
17
+ crumbs.push({
18
+ action: 'create',
19
+ cmd: `jsn dev ${name} create --name ... --label "..."`,
20
+ description: `Create ${vowelArticle(singular)} ${singular}`,
21
+ });
22
+ }
23
+ crumbs.push({
24
+ action: 'show',
25
+ cmd: `jsn dev ${name} show <name_or_sys_id>`,
26
+ description: `Show ${singular} details`,
27
+ });
28
+ return crumbs;
29
+ }
30
+
12
31
  function toSingular(name, explicitSingular) {
13
32
  if (explicitSingular) return explicitSingular;
14
33
  if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
@@ -65,12 +84,92 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
65
84
  .option('columns', { alias: 'c', type: 'string', describe: 'Comma-separated columns (e.g. "name,label,super_class")' })
66
85
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
67
86
  handler: wrap(async (argv, app) => {
87
+ const query = argv.query || '';
68
88
  const columns = argv.columns ? argv.columns.split(',') : defaultColumns;
89
+ const limit = argv.limit;
90
+
91
+ // Interactive picker in TTY with auto format and no explicit query
92
+ const interactive = isTTY(process.stdout) && isTTY(process.stdin);
93
+ if (interactive && app.output.getFormat() === FormatAuto && !query) {
94
+ const pickerColumns = ['sys_id', 'name', ...columns.filter(c => c !== 'name' && c !== 'sys_id')];
95
+ const pickerParams = new URLSearchParams();
96
+ pickerParams.set('sysparm_limit', String(limit));
97
+ pickerParams.set('sysparm_display_value', 'all');
98
+ pickerParams.set('sysparm_fields', pickerColumns.join(','));
99
+ pickerParams.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
100
+
101
+ const pickerRecords = await app.sdk.list(table, pickerParams);
102
+ if (pickerRecords.length === 0) {
103
+ app.ok({
104
+ table,
105
+ count: 0,
106
+ columns: pickerColumns,
107
+ records: [],
108
+ pagination: { limit, offset: 0 },
109
+ context: { instance_url: app.getEffectiveInstance() },
110
+ }, {
111
+ summary: `0 ${name}(s)`,
112
+ breadcrumbs: buildHints(name, singular, readOnly),
113
+ });
114
+ return;
115
+ }
116
+
117
+ const choices = pickerRecords.map(r => {
118
+ const recordName = getStringField(r, 'name') || getStringField(r, 'sys_id');
119
+ const scope = getStringField(r, 'sys_scope') || '';
120
+ let label = recordName;
121
+ if (scope && scope !== 'global') label += ` [${scope}]`;
122
+ return { name: label, value: recordName };
123
+ });
124
+
125
+ let selectedName;
126
+ try {
127
+ selectedName = await search({
128
+ message: `Select ${vowelArticle(singular)} ${singular}:`,
129
+ source: async (input) => {
130
+ if (!input) return choices;
131
+ const term = input.toLowerCase();
132
+ return choices.filter(c => c.name.toLowerCase().includes(term));
133
+ },
134
+ });
135
+ } catch (err) {
136
+ if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
137
+ return;
138
+ }
139
+ throw err;
140
+ }
141
+
142
+ if (selectedName) {
143
+ const showParams = new URLSearchParams();
144
+ showParams.set('sysparm_query', `name=${selectedName}`);
145
+ showParams.set('sysparm_display_value', 'all');
146
+ showParams.set('sysparm_limit', '1');
147
+ const showRecords = await app.sdk.list(table, showParams);
148
+ if (showRecords.length === 0) {
149
+ throw new Error(`${singular} not found: ${selectedName}`);
150
+ }
151
+ const record = showRecords[0];
152
+ record._context = { instance_url: app.getEffectiveInstance(), table };
153
+ if (opts.onShow) {
154
+ await opts.onShow(record, app);
155
+ }
156
+ app.ok(record, {
157
+ summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${selectedName}`,
158
+ breadcrumbs: [
159
+ ...(readOnly ? [] : [{ action: 'update', cmd: `jsn dev ${name} update ${selectedName} --data '{...}'`, description: `Update this ${singular}` }]),
160
+ { action: 'list', cmd: `jsn dev ${name} list`, description: `Back to all ${name}` },
161
+ ],
162
+ });
163
+ }
164
+ return;
165
+ }
166
+
167
+ // Non-interactive: text/table output
69
168
  const params = new URLSearchParams();
70
- params.set('sysparm_limit', String(argv.limit));
169
+ params.set('sysparm_limit', String(limit));
71
170
  params.set('sysparm_display_value', 'all');
72
171
  params.set('sysparm_fields', ['sys_id', ...columns].join(','));
73
- const q = argv.query ? argv.query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
172
+ const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
74
173
  params.set('sysparm_query', q);
75
174
  const records = await app.sdk.list(table, params);
76
175
  app.ok({
@@ -79,7 +178,10 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
79
178
  columns,
80
179
  records: records.map(r => formatRecordForDisplay(r, columns)),
81
180
  context: { instance_url: app.getEffectiveInstance() },
82
- }, { summary: `${records.length} ${name}(s)` });
181
+ }, {
182
+ summary: `${records.length} ${name}(s)`,
183
+ breadcrumbs: buildHints(name, singular, readOnly),
184
+ });
83
185
  }),
84
186
  })
85
187
  .command({
@@ -123,9 +225,11 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
123
225
  .command({
124
226
  command: 'create',
125
227
  describe: `Create a new ${singular}`,
126
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2"}\')' }),
228
+ builder: (y) => y
229
+ .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
230
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
127
231
  handler: wrap(async (argv, app) => {
128
- const recordData = JSON.parse(argv.data);
232
+ const recordData = parseDataArg(argv);
129
233
  const record = await app.sdk.create(table, recordData);
130
234
  app.ok(record, {
131
235
  summary: `Created ${singular}`,
@@ -138,7 +242,9 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
138
242
  .command({
139
243
  command: 'update <identifier>',
140
244
  describe: `Update ${vowelArticle(singular)} ${singular}`,
141
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2"}\')' }),
245
+ builder: (y) => y
246
+ .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
247
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
142
248
  handler: wrap(async (argv, app) => {
143
249
  const id = argv.identifier;
144
250
  const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
@@ -159,7 +265,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
159
265
  }
160
266
  }
161
267
 
162
- const recordData = JSON.parse(argv.data);
268
+ const recordData = parseDataArg(argv);
163
269
  const updated = await app.sdk.update(table, sysID, recordData);
164
270
  app.ok(updated, { summary: `Updated ${singular} ${id}` });
165
271
  }),
@@ -215,9 +321,20 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
215
321
  aliases: aliases || [],
216
322
  describe: `Manage ${name} (e.g. "${name} list --query nameLIKEincident")`,
217
323
  builder,
218
- handler: (argv) => {
219
- if (argv.help) return;
220
- argv.showHelp?.();
324
+ handler: (_argv) => {
325
+ // This handler only runs when no subcommand is matched (the default case).
326
+ // Show help for the subcommands available.
327
+ console.log(`Manage ${name} from the ${table} table.`);
328
+ console.log('');
329
+ console.log('Available subcommands:');
330
+ console.log(' list List all records');
331
+ console.log(' show Show record details');
332
+ if (!readOnly) {
333
+ console.log(' create Create a new record');
334
+ console.log(' update Update a record');
335
+ console.log(' delete Delete a record');
336
+ }
337
+ console.log(`\nRun "jsn dev ${name} <command> --help" for details.`);
221
338
  },
222
339
  };
223
340
  }
@@ -1,17 +1,38 @@
1
+ import fs from 'node:fs';
2
+
1
3
  export function evalCmd(wrap) {
2
4
  return {
3
5
  command: 'eval',
4
- describe: 'Execute background scripts (eval)',
6
+ describe: 'Execute background scripts on the instance',
5
7
  builder: (yargs) => {
6
8
  return yargs
7
- .option('script', { alias: 's', type: 'string', describe: 'JavaScript code to execute (required)', demandOption: true });
9
+ .option('script', { alias: 's', type: 'string', describe: 'JavaScript code to execute' })
10
+ .option('file', { alias: 'f', type: 'string', describe: 'Read script from file' });
8
11
  },
9
12
  handler: wrap(async (argv, app) => {
13
+ let script;
14
+
15
+ if (argv.file) {
16
+ script = fs.readFileSync(argv.file, 'utf-8');
17
+ } else if (argv.script) {
18
+ script = argv.script;
19
+ } else {
20
+ throw new Error('--script or --file is required');
21
+ }
22
+
23
+ const output = await app.sdk.executeScript(script);
10
24
  app.ok({
11
- status: 'stub',
12
- message: 'Background script execution is not yet implemented',
13
- script: argv.script,
14
- }, { summary: 'Background script execution (stub)' });
25
+ script,
26
+ output,
27
+ instance: app.getEffectiveInstance(),
28
+ }, {
29
+ summary: 'Script executed',
30
+ breadcrumbs: [{
31
+ action: 'eval',
32
+ cmd: 'jsn dev eval --script \'...\'',
33
+ description: 'Execute a background script on the ServiceNow instance',
34
+ }],
35
+ });
15
36
  }),
16
37
  };
17
38
  }
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField } from '../../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
2
2
 
3
3
  export function flowsCmd(wrap) {
4
4
  return {
@@ -17,6 +17,23 @@ export function flowsCmd(wrap) {
17
17
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
18
  handler: wrap(async (argv, app) => {
19
19
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'active', 'description', 'sys_created_by', 'sys_updated_on'];
20
+ const query = argv.query || '';
21
+
22
+ // Interactive picker
23
+ const picked = await interactiveList({
24
+ app, table: 'sys_hub_flow', singular: 'flow', columns, limit: argv.limit, query, labelField: 'name',
25
+ formatLabel: r => `${getStringField(r, 'name')} ${getStringField(r, 'active') === 'true' ? '' : '[inactive]'}`,
26
+ });
27
+ if (picked) {
28
+ const inspection = await app.sdk.inspectFlow(picked.sys_id);
29
+ const formatted = formatFlowInspection(inspection, app.getEffectiveInstance());
30
+ return app.ok({ ...inspection, _formatted: formatted }, {
31
+ summary: `Flow: ${inspection.flow.name}`,
32
+ breadcrumbs: [{ action: 'list', cmd: 'jsn dev flows list', description: 'Back to all flows' }],
33
+ });
34
+ }
35
+
36
+ // Text/table fallback
20
37
  const params = new URLSearchParams();
21
38
  params.set('sysparm_limit', String(argv.limit));
22
39
  params.set('sysparm_display_value', 'all');
@@ -48,6 +65,34 @@ export function flowsCmd(wrap) {
48
65
  ],
49
66
  });
50
67
  }),
68
+ })
69
+ .command({
70
+ command: 'create',
71
+ describe: 'Create a new flow (not yet implemented)',
72
+ builder: (y) => y
73
+ .option('data', { type: 'string', describe: 'JSON data for the flow' }),
74
+ handler: wrap(async (_argv, _app) => {
75
+ throw new Error('Flow creation requires the Flow Designer GraphQL API - not yet implemented.\n'
76
+ + 'Use the ServiceNow web UI to create flows, then use "jsn dev flows list" to view them.');
77
+ }),
78
+ })
79
+ .command({
80
+ command: 'update <identifier>',
81
+ describe: 'Update a flow (not yet implemented)',
82
+ builder: (y) => y
83
+ .option('data', { type: 'string', describe: 'JSON data to update' }),
84
+ handler: wrap(async (_argv, _app) => {
85
+ throw new Error('Flow updates require the Flow Designer GraphQL API - not yet implemented.\n'
86
+ + 'Use the ServiceNow web UI to update flows, then use "jsn dev flows list" to view them.');
87
+ }),
88
+ })
89
+ .command({
90
+ command: 'delete <identifier>',
91
+ describe: 'Delete a flow (not yet implemented)',
92
+ handler: wrap(async (_argv, _app) => {
93
+ throw new Error('Flow deletion requires the Flow Designer GraphQL API - not yet implemented.\n'
94
+ + 'Use the ServiceNow web UI to delete flows.');
95
+ }),
51
96
  });
52
97
  },
53
98
  handler: () => {},
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay } from '../../helpers.js';
1
+ import { formatRecordForDisplay, interactiveList, getStringField } from '../../helpers.js';
2
2
 
3
3
  export function logsCmd(wrap) {
4
4
  return {
@@ -17,11 +17,24 @@ export function logsCmd(wrap) {
17
17
  .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
18
18
  handler: wrap(async (argv, app) => {
19
19
  const columns = argv.columns ? argv.columns.split(',') : ['level', 'message', 'source', 'created'];
20
+ const query = argv.query || '';
21
+
22
+ // Try interactive picker first
23
+ const picked = await interactiveList({
24
+ app, table: 'syslog', singular: 'log entry', columns, limit: argv.limit, query, labelField: 'message',
25
+ formatLabel: r => `[${getStringField(r, 'level') || '?'}] ${(getStringField(r, 'message') || '').substring(0, 80)}`,
26
+ });
27
+ if (picked) {
28
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'syslog' };
29
+ return app.ok(picked, { summary: `Log entry` });
30
+ }
31
+
32
+ // Fall back to text/table
20
33
  const params = new URLSearchParams();
21
34
  params.set('sysparm_limit', String(argv.limit));
22
35
  params.set('sysparm_display_value', 'all');
23
36
  params.set('sysparm_fields', ['sys_id', ...columns].join(','));
24
- const q = argv.query ? argv.query + '^ORDERBYDESCsys_created_on' : 'ORDERBYDESCsys_created_on';
37
+ const q = query ? query + '^ORDERBYDESCsys_created_on' : 'ORDERBYDESCsys_created_on';
25
38
  params.set('sysparm_query', q);
26
39
  const records = await app.sdk.list('syslog', params);
27
40
  app.ok({
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField } from '../../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
2
2
 
3
3
  export function scopesCmd(wrap) {
4
4
  return {
@@ -17,6 +17,17 @@ export function scopesCmd(wrap) {
17
17
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
18
  handler: wrap(async (argv, app) => {
19
19
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'scope', 'short_description', 'active'];
20
+ const query = argv.query || '';
21
+
22
+ const picked = await interactiveList({
23
+ app, table: 'sys_scope', singular: 'scope', columns, limit: argv.limit, query, labelField: 'name',
24
+ formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'scope') || '?'}]`,
25
+ });
26
+ if (picked) {
27
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_scope' };
28
+ return app.ok(picked, { summary: `Scope: ${getStringField(picked, 'name')}` });
29
+ }
30
+
20
31
  const params = new URLSearchParams();
21
32
  params.set('sysparm_limit', String(argv.limit));
22
33
  params.set('sysparm_display_value', 'all');
@@ -89,8 +100,57 @@ export function scopesCmd(wrap) {
89
100
  app.ok({ scope: argv.scope, sys_id: scopeSysID }, { summary: `Current scope: ${argv.scope}` });
90
101
  }),
91
102
  })
103
+ .command({
104
+ command: 'create',
105
+ describe: 'Create a new application scope',
106
+ builder: (y) => y
107
+ .option('name', { alias: 'n', type: 'string', demandOption: true, describe: 'Application name' })
108
+ .option('scope', { type: 'string', describe: 'Scope value (auto-generated from name if omitted)' }),
109
+ handler: wrap(async (argv, app) => {
110
+ let scope = argv.scope;
111
+ if (!scope) {
112
+ // Auto-generate scope from name: lowercase, replace spaces/special chars
113
+ scope = 'x_' + argv.name.toLowerCase()
114
+ .replace(/[^a-z0-9_]/g, '_')
115
+ .replace(/_+/g, '_')
116
+ .replace(/^_|_$/g, '')
117
+ .substring(0, 38); // x_ + max 35 chars = 37, leave room
118
+ }
119
+ // Check for existing scope
120
+ const existing = await app.sdk.list('sys_scope', new URLSearchParams({
121
+ sysparm_query: `scope=${scope}`,
122
+ sysparm_limit: '1',
123
+ }));
124
+ if (existing.length > 0) {
125
+ throw new Error(`Scope '${scope}' already exists. Use a different name or --scope flag.`);
126
+ }
127
+ const record = await app.sdk.create('sys_scope', {
128
+ name: argv.name,
129
+ scope,
130
+ short_description: argv.name,
131
+ });
132
+ app.ok(record, {
133
+ summary: `Created scope: ${scope}`,
134
+ breadcrumbs: [{
135
+ action: 'show',
136
+ cmd: `jsn dev scopes show ${scope}`,
137
+ description: 'View the new scope',
138
+ }],
139
+ });
140
+ }),
141
+ })
92
142
 
93
143
  },
94
- handler: () => {},
144
+ handler: (argv) => {
145
+ if (!argv._[1]) {
146
+ console.log('Manage ServiceNow application scopes.\n');
147
+ console.log('Commands:');
148
+ console.log(' list List application scopes');
149
+ console.log(' show <scope> Show a scope');
150
+ console.log(' set <scope> Set the current application scope');
151
+ console.log(' create Create a new application scope');
152
+ console.log('\nRun "jsn dev scopes <command> --help" for details.');
153
+ }
154
+ },
95
155
  };
96
156
  }
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField } from '../../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
2
2
 
3
3
  export function updateSetsCmd(wrap) {
4
4
  return {
@@ -17,6 +17,17 @@ export function updateSetsCmd(wrap) {
17
17
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
18
  handler: wrap(async (argv, app) => {
19
19
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'state', 'application'];
20
+ const query = argv.query || '';
21
+
22
+ const picked = await interactiveList({
23
+ app, table: 'sys_update_set', singular: 'update set', columns, limit: argv.limit, query, labelField: 'name',
24
+ formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'state') || '?'}]`,
25
+ });
26
+ if (picked) {
27
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_update_set' };
28
+ return app.ok(picked, { summary: `Update set: ${getStringField(picked, 'name')}` });
29
+ }
30
+
20
31
  const params = new URLSearchParams();
21
32
  params.set('sysparm_limit', String(argv.limit));
22
33
  params.set('sysparm_display_value', 'all');
@@ -90,8 +101,40 @@ export function updateSetsCmd(wrap) {
90
101
  app.ok({ update_set: argv.name, sys_id: sysID }, { summary: `Current update set: ${argv.name}` });
91
102
  }),
92
103
  })
104
+ .command({
105
+ command: 'create',
106
+ describe: 'Create a new update set',
107
+ builder: (y) => y
108
+ .option('name', { alias: 'n', type: 'string', demandOption: true, describe: 'Update set name' })
109
+ .option('description', { type: 'string', describe: 'Description' }),
110
+ handler: wrap(async (argv, app) => {
111
+ const record = await app.sdk.create('sys_update_set', {
112
+ name: argv.name,
113
+ description: argv.description || argv.name,
114
+ state: 'in progress',
115
+ });
116
+ app.ok(record, {
117
+ summary: `Created update set: ${argv.name}`,
118
+ breadcrumbs: [{
119
+ action: 'set',
120
+ cmd: `jsn dev updatesets set "${argv.name}"`,
121
+ description: 'Set as current update set',
122
+ }],
123
+ });
124
+ }),
125
+ })
93
126
 
94
127
  },
95
- handler: () => {},
128
+ handler: (argv) => {
129
+ if (!argv._[1]) {
130
+ console.log('Manage ServiceNow update sets.\n');
131
+ console.log('Commands:');
132
+ console.log(' list List update sets');
133
+ console.log(' show <name> Show an update set');
134
+ console.log(' set <name> Set the current update set');
135
+ console.log(' create Create a new update set');
136
+ console.log('\nRun "jsn dev updatesets <command> --help" for details.');
137
+ }
138
+ },
96
139
  };
97
140
  }
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField } from '../helpers.js';
2
2
 
3
3
  export function groupMembersCmd(wrap) {
4
4
  return {
@@ -32,7 +32,120 @@ export function groupMembersCmd(wrap) {
32
32
  }, { summary: `${records.length} group member(s)` });
33
33
  }),
34
34
  })
35
+ .command({
36
+ command: 'add',
37
+ describe: 'Add a user to a group',
38
+ builder: (y) => y
39
+ .option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
40
+ .option('user', { alias: 'u', type: 'string', demandOption: true, describe: 'Username or sys_id (required)' }),
41
+ handler: wrap(async (argv, app) => {
42
+ const groupName = argv.group;
43
+ const userName = argv.user;
44
+
45
+ // Resolve group sys_id
46
+ const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
47
+ const groupParams = new URLSearchParams();
48
+ groupParams.set('sysparm_query', isGroupSysID ? `sys_id=${groupName}` : `name=${groupName}`);
49
+ groupParams.set('sysparm_limit', '1');
50
+ groupParams.set('sysparm_fields', 'sys_id');
51
+ const groupRecords = await app.sdk.list('sys_user_group', groupParams);
52
+ if (groupRecords.length === 0) {
53
+ throw new Error(`Group not found: ${groupName}`);
54
+ }
55
+ const groupSysID = getStringField(groupRecords[0], 'sys_id');
56
+
57
+ // Resolve user sys_id
58
+ const isUserSysID = userName.length === 32 && /^[0-9a-fA-F]+$/.test(userName);
59
+ const userParams = new URLSearchParams();
60
+ userParams.set('sysparm_query', isUserSysID ? `sys_id=${userName}` : `user_name=${userName}`);
61
+ userParams.set('sysparm_limit', '1');
62
+ userParams.set('sysparm_fields', 'sys_id');
63
+ const userRecords = await app.sdk.list('sys_user', userParams);
64
+ if (userRecords.length === 0) {
65
+ throw new Error(`User not found: ${userName}`);
66
+ }
67
+ const userSysID = getStringField(userRecords[0], 'sys_id');
68
+
69
+ // Check if membership already exists
70
+ const checkParams = new URLSearchParams();
71
+ checkParams.set('sysparm_query', `group=${groupSysID}^user=${userSysID}`);
72
+ checkParams.set('sysparm_limit', '1');
73
+ checkParams.set('sysparm_fields', 'sys_id');
74
+ const existing = await app.sdk.list('sys_user_grmember', checkParams);
75
+ if (existing.length > 0) {
76
+ throw new Error(`User ${userName} is already a member of group ${groupName}`);
77
+ }
78
+
79
+ // Create the membership
80
+ const record = await app.sdk.create('sys_user_grmember', {
81
+ group: groupSysID,
82
+ user: userSysID,
83
+ });
84
+ app.ok(record, {
85
+ summary: `Added ${userName} to ${groupName}`,
86
+ breadcrumbs: [
87
+ { action: 'list', cmd: `jsn groupmembers list --query "group.name=${groupName}"`, description: 'List group members' },
88
+ { action: 'remove', cmd: `jsn groupmembers remove --group "${groupName}" --user "${userName}"`, description: 'Remove this member' },
89
+ ],
90
+ });
91
+ }),
92
+ })
93
+ .command({
94
+ command: 'remove',
95
+ describe: 'Remove a user from a group',
96
+ builder: (y) => y
97
+ .option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
98
+ .option('user', { alias: 'u', type: 'string', demandOption: true, describe: 'Username or sys_id (required)' }),
99
+ handler: wrap(async (argv, app) => {
100
+ const groupName = argv.group;
101
+ const userName = argv.user;
35
102
 
103
+ // Resolve group sys_id
104
+ const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
105
+ const groupParams = new URLSearchParams();
106
+ groupParams.set('sysparm_query', isGroupSysID ? `sys_id=${groupName}` : `name=${groupName}`);
107
+ groupParams.set('sysparm_limit', '1');
108
+ groupParams.set('sysparm_fields', 'sys_id');
109
+ const groupRecords = await app.sdk.list('sys_user_group', groupParams);
110
+ if (groupRecords.length === 0) {
111
+ throw new Error(`Group not found: ${groupName}`);
112
+ }
113
+ const groupSysID = getStringField(groupRecords[0], 'sys_id');
114
+
115
+ // Resolve user sys_id
116
+ const isUserSysID = userName.length === 32 && /^[0-9a-fA-F]+$/.test(userName);
117
+ const userParams = new URLSearchParams();
118
+ userParams.set('sysparm_query', isUserSysID ? `sys_id=${userName}` : `user_name=${userName}`);
119
+ userParams.set('sysparm_limit', '1');
120
+ userParams.set('sysparm_fields', 'sys_id');
121
+ const userRecords = await app.sdk.list('sys_user', userParams);
122
+ if (userRecords.length === 0) {
123
+ throw new Error(`User not found: ${userName}`);
124
+ }
125
+ const userSysID = getStringField(userRecords[0], 'sys_id');
126
+
127
+ // Find the membership record
128
+ const findParams = new URLSearchParams();
129
+ findParams.set('sysparm_query', `group=${groupSysID}^user=${userSysID}`);
130
+ findParams.set('sysparm_limit', '1');
131
+ findParams.set('sysparm_fields', 'sys_id');
132
+ const membershipRecords = await app.sdk.list('sys_user_grmember', findParams);
133
+ if (membershipRecords.length === 0) {
134
+ throw new Error(`User ${userName} is not a member of group ${groupName}`);
135
+ }
136
+ const membershipSysID = getStringField(membershipRecords[0], 'sys_id');
137
+
138
+ // Delete the membership
139
+ await app.sdk.delete('sys_user_grmember', membershipSysID);
140
+ app.ok({ user: userName, group: groupName }, {
141
+ summary: `Removed ${userName} from ${groupName}`,
142
+ breadcrumbs: [
143
+ { action: 'list', cmd: `jsn groupmembers list --query "group.name=${groupName}"`, description: 'List group members' },
144
+ { action: 'add', cmd: `jsn groupmembers add --group "${groupName}" --user "${userName}"`, description: 'Add user back to group' },
145
+ ],
146
+ });
147
+ }),
148
+ });
36
149
  },
37
150
  handler: () => {},
38
151
  };