@jacebenson/jsn 4.3.11-dev.69 → 4.3.12-dev.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.11-dev.69",
3
+ "version": "4.3.12-dev.70",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,6 +3,14 @@ import process from 'node:process';
3
3
 
4
4
  const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5
5
  const names = (x) => typeof x === 'string' ? [x] : (x || []);
6
+
7
+ /**
8
+ * A repeatable option may be declared either way: `type: 'array'` (this
9
+ * adapter's historic spelling) or `array: true` (the yargs spelling most
10
+ * contributors reach for). Both must accumulate; honoring only the first
11
+ * silently kept the LAST value of `-f a=1 -f b=2` and dropped the rest.
12
+ */
13
+ const isArrayOption = (spec) => spec?.type === 'array' || spec?.array === true;
6
14
  const commandName = (command) => command.trim().split(/\s+/)[0];
7
15
  const positionSpec = (command) => [...command.matchAll(/([<[])([^>\]]+)[>\]]/g)].map((m) => ({ name: m[2], required: m[1] === '<' }));
8
16
 
@@ -189,7 +197,7 @@ export class LocalCLI {
189
197
  _assign(argv, key, value, spec, opts = {}) {
190
198
  const validate = opts.validate !== false;
191
199
  const actual = spec?.name || key;
192
- const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
200
+ const values = isArrayOption(spec) ? (Array.isArray(value) ? value : [value]) : [value];
193
201
  if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
194
202
  this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
195
203
  return false;
@@ -201,7 +209,7 @@ export class LocalCLI {
201
209
  const converted = spec?.type === 'boolean'
202
210
  ? (value === 'true' ? true : value === 'false' ? false : value)
203
211
  : spec?.type === 'number' ? Number(value) : value;
204
- if (spec?.type === 'array') argv[actual] = [...(argv[actual] || []), ...values];
212
+ if (isArrayOption(spec)) argv[actual] = [...(argv[actual] || []), ...values];
205
213
  else argv[actual] = converted;
206
214
  argv[camel(actual)] = argv[actual];
207
215
  for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
@@ -210,8 +218,8 @@ export class LocalCLI {
210
218
  const options = this._optionConfig(path);
211
219
  const defs = {};
212
220
  for (const [key, spec] of options) defs[key] = {
213
- type: spec.type === 'number' || spec.type === 'array' ? 'string' : (spec.type || 'boolean'),
214
- multiple: spec.type === 'array',
221
+ type: spec.type === 'number' || isArrayOption(spec) ? 'string' : (spec.type || 'boolean'),
222
+ multiple: isArrayOption(spec),
215
223
  };
216
224
  args = args.map((arg) => {
217
225
  if (/^-[^-]=/.test(arg)) return `--${arg.slice(1)}`;
@@ -1,6 +1,6 @@
1
1
  // Generic table-based CRUD command builder
2
2
 
3
- import { formatRecordForDisplay, getStringField, parseDataArg, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
3
+ import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
4
4
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
5
5
  import { getCurrentUser, getCurrentApplication } from '../context.js';
6
6
  import { FormatAuto } from '../output.js';
@@ -108,7 +108,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
108
108
  if (!readOnly) {
109
109
  crumbs.unshift(
110
110
  { action: 'delete', cmd: `jsn ${name} delete ${id}`, description: `Delete this ${singular}` },
111
- { action: 'update', cmd: `jsn ${name} update ${id} --data '{...}'`, description: `Update this ${singular}` }
111
+ { action: 'update', cmd: `jsn ${name} update ${id} -f name=value`, description: `Update this ${singular}` }
112
112
  );
113
113
  }
114
114
  return crumbs;
@@ -152,7 +152,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
152
152
  app.ok(record, {
153
153
  summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
154
154
  breadcrumbs: [
155
- ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} --data '{...}'`, description: `Update this ${singular}` }]),
155
+ ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} -f name=value`, description: `Update this ${singular}` }]),
156
156
  { action: 'list', cmd: `jsn ${name} list`, description: `Back to all ${name}` },
157
157
  ],
158
158
  });
@@ -215,10 +215,12 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
215
215
  command: 'create',
216
216
  describe: `Create a new ${singular}`,
217
217
  builder: (y) => y
218
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
219
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
218
+ .option('data', { type: 'string', describe: 'JSON fields' })
219
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
220
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
221
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
220
222
  handler: wrap(async (argv, app) => {
221
- const recordData = parseDataArg(argv);
223
+ const recordData = buildRecordPayload(argv);
222
224
  const record = await app.sdk.create(table, recordData);
223
225
  app.ok(record, {
224
226
  summary: `Created ${singular}`,
@@ -232,8 +234,10 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
232
234
  command: 'update <identifier>',
233
235
  describe: `Update ${vowelArticle(singular)} ${singular}`,
234
236
  builder: (y) => y
235
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
236
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
237
+ .option('data', { type: 'string', describe: 'JSON fields' })
238
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
239
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
240
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
237
241
  handler: wrap(async (argv, app) => {
238
242
  const id = argv.identifier;
239
243
  const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
@@ -242,7 +246,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
242
246
  }
243
247
 
244
248
  const sysID = unwrapSysId(record);
245
- const recordData = parseDataArg(argv);
249
+ const recordData = buildRecordPayload(argv);
246
250
  const updated = await app.sdk.update(table, sysID, recordData);
247
251
  app.ok(updated, { summary: `Updated ${singular} ${id}` });
248
252
  }),
@@ -1,7 +1,7 @@
1
1
  // Generic command builder for CRUD operations on a ServiceNow table
2
2
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
3
 
4
- import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
5
5
  import { resolveRecord, resolveSysId } from '../resolve-record.js';
6
6
  import { FormatAuto } from '../output.js';
7
7
  import { declareCapabilities } from '../capabilities.js';
@@ -73,7 +73,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
73
73
  app.ok(record, {
74
74
  summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
75
75
  breadcrumbs: [
76
- { action: 'update', cmd: `jsn ${alias} update ${number} --data '{...}'`, description: `Update this ${displayName}` },
76
+ { action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
77
77
  { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
78
78
  ],
79
79
  });
@@ -139,7 +139,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
139
139
  app.ok(record, {
140
140
  summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
141
141
  breadcrumbs: [
142
- { action: 'update', cmd: `jsn ${alias} update ${number} --data '{...}'`, description: `Update this ${displayName}` },
142
+ { action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
143
143
  { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
144
144
  ],
145
145
  });
@@ -151,16 +151,16 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
151
151
  builder: (y) => y
152
152
  .option('description', { alias: 'd', type: 'string', describe: 'Short description' })
153
153
  .option('priority', { type: 'string', describe: 'Priority (1-5)' })
154
- .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
154
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' })
155
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
156
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
157
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
155
158
  handler: wrap(async (argv, app) => {
156
- const recordData = {};
157
- if (argv.data) {
158
- Object.assign(recordData, JSON.parse(argv.data));
159
- }
159
+ const recordData = buildRecordPayload(argv, { required: false });
160
160
  if (argv.description) recordData.short_description = argv.description;
161
161
  if (argv.priority) recordData.priority = argv.priority;
162
162
  if (!recordData.short_description) {
163
- throw new Error('short_description is required (use --description or --data)');
163
+ throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
164
164
  }
165
165
  const record = await app.sdk.create(table, recordData);
166
166
  app.ok(record, {
@@ -175,10 +175,13 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
175
175
  command: 'update <number>',
176
176
  describe: `Update a ${displayName}`,
177
177
  builder: (y) => y
178
- .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
178
+ .option('data', { type: 'string', describe: 'JSON data to update' })
179
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
180
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
181
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
179
182
  handler: wrap(async (argv, app) => {
180
183
  const number = argv.number;
181
- const recordData = JSON.parse(argv.data);
184
+ const recordData = buildRecordPayload(argv);
182
185
  const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
183
186
  const updated = await app.sdk.update(table, sysID, recordData);
184
187
  app.ok(updated, {
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch, buildRecordPayload } from '../helpers.js';
2
2
  import { declareCapabilities } from '../capabilities.js';
3
3
 
4
4
  declareCapabilities('groups', { mutationSubcommands: ['create', 'update', 'delete'] });
@@ -94,12 +94,12 @@ export function groupsCmd(wrap) {
94
94
  builder: (y) => y
95
95
  .option('name', { alias: 'n', type: 'string', describe: 'Group name' })
96
96
  .option('manager', { type: 'string', describe: 'Manager name or sys_id' })
97
- .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
97
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' })
98
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
99
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
100
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
98
101
  handler: wrap(async (argv, app) => {
99
- const recordData = {};
100
- if (argv.data) {
101
- Object.assign(recordData, JSON.parse(argv.data));
102
- }
102
+ const recordData = buildRecordPayload(argv, { required: false });
103
103
  if (argv.name) recordData.name = argv.name;
104
104
  if (argv.manager) recordData.manager = argv.manager;
105
105
  if (!recordData.name) {
@@ -118,11 +118,14 @@ export function groupsCmd(wrap) {
118
118
  command: 'update <name>',
119
119
  describe: 'Update a group by name or sys_id',
120
120
  builder: (y) => y
121
- .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
121
+ .option('data', { type: 'string', describe: 'JSON data to update' })
122
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
123
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
124
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
122
125
  handler: wrap(async (argv, app) => {
123
126
  const id = argv.name;
124
127
  assertSafeExactMatch(id);
125
- const recordData = JSON.parse(argv.data);
128
+ const recordData = buildRecordPayload(argv);
126
129
  const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
127
130
  const params = new URLSearchParams();
128
131
  params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
3
+ import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
 
6
6
  declareCapabilities('records', {
@@ -142,7 +142,7 @@ export function recordsCmd(wrap) {
142
142
  }
143
143
 
144
144
  const breadcrumbs = [
145
- { action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
145
+ { action: 'create', cmd: `jsn records create --table ${table} -f name=value`, description: 'Create a new record' },
146
146
  { action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
147
147
  { action: 'columns', cmd: `jsn columns --table ${table}`, description: 'View available columns' },
148
148
  ];
@@ -221,12 +221,13 @@ export function recordsCmd(wrap) {
221
221
  describe: 'Create a new record',
222
222
  builder: (y) => y
223
223
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
224
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
224
+ .option('data', { type: 'string', describe: 'JSON fields' })
225
225
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
226
226
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
227
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
227
228
  .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
228
229
  handler: wrap(async (argv, app) => {
229
- const recordData = parseDataArg(argv);
230
+ const recordData = buildRecordPayload(argv);
230
231
  const warnings = checkDerivedFields(argv.table, recordData);
231
232
  for (const w of warnings) {
232
233
  process.stderr.write(`⚠ ${w.hint}\n`);
@@ -249,12 +250,13 @@ export function recordsCmd(wrap) {
249
250
  builder: (y) => y
250
251
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
251
252
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
252
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
253
+ .option('data', { type: 'string', describe: 'JSON fields' })
253
254
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
254
255
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
256
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
255
257
  .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
256
258
  handler: wrap(async (argv, app) => {
257
- const recordData = parseDataArg(argv);
259
+ const recordData = buildRecordPayload(argv);
258
260
  const warnings = checkDerivedFields(argv.table, recordData);
259
261
  for (const w of warnings) {
260
262
  process.stderr.write(`⚠ ${w.hint}\n`);
@@ -401,22 +403,15 @@ export function recordsCmd(wrap) {
401
403
  builder: (y) => y
402
404
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
403
405
  .option('query', { type: 'string', demandOption: true, describe: 'Encoded query selecting the records to update (e.g. "priority=1^state=1")' })
404
- .option('set', { type: 'string', demandOption: true, describe: 'JSON object of fields to set (e.g. \'{"state":"3"}\')' })
406
+ .option('set', { type: 'string', describe: 'JSON object of fields to set' })
407
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
405
408
  .option('dry-run', { type: 'boolean', default: true, describe: 'Preview the count + a sample without mutating (default)' })
406
409
  .option('execute', { type: 'boolean', default: false, describe: 'Perform the update after confirmation' })
407
410
  .option('limit', { type: 'number', default: 200, describe: 'Max records to update when executing' })
408
411
  .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation (with --execute)' }),
409
412
  handler: wrap(async (argv, app) => {
410
413
  app.requireInstance();
411
- let set;
412
- try {
413
- set = JSON.parse(argv.set);
414
- } catch (e) {
415
- throw new Error(`Invalid --set JSON: ${e.message}`, { cause: e });
416
- }
417
- if (!set || typeof set !== 'object' || Array.isArray(set)) {
418
- throw new Error('--set must be a JSON object of field=value pairs');
419
- }
414
+ const set = buildRecordPayload({ ...argv, data: argv.set }, { required: true });
420
415
  if (!argv['dry-run'] && !argv.execute) {
421
416
  argv.execute = true; // explicit --no-dry-run implies execute intent
422
417
  }
@@ -53,7 +53,7 @@ async function enrichedShowHandler(argv, app) {
53
53
  app.ok(record, {
54
54
  summary: `Request ${number}`,
55
55
  breadcrumbs: [
56
- { action: 'update', cmd: `jsn req update ${number} --data '{...}'`, description: 'Update this request' },
56
+ { action: 'update', cmd: `jsn req update ${number} -f state=2`, description: 'Update this request' },
57
57
  { action: 'list', cmd: `jsn req list`, description: 'Back to all requests' },
58
58
  ],
59
59
  });
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
2
2
  import { resolveRecord, resolveSysId } from '../resolve-record.js';
3
3
  import { declareCapabilities } from '../capabilities.js';
4
4
 
@@ -33,7 +33,7 @@ export function ticketsCmd(wrap) {
33
33
  const records = await app.sdk.list('task', params);
34
34
  const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
35
35
  const breadcrumbs = [
36
- { action: 'create', cmd: 'jsn tickets create --data \'{...}\'', description: 'Create a new ticket' },
36
+ { action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
37
37
  ];
38
38
  if (records.length === argv.limit) {
39
39
  breadcrumbs.push({
@@ -71,9 +71,13 @@ export function ticketsCmd(wrap) {
71
71
  .command({
72
72
  command: 'create',
73
73
  describe: 'Create a new ticket',
74
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
74
+ builder: (y) => y
75
+ .option('data', { type: 'string', describe: 'JSON fields' })
76
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
77
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
78
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
75
79
  handler: wrap(async (argv, app) => {
76
- const recordData = JSON.parse(argv.data);
80
+ const recordData = buildRecordPayload(argv);
77
81
  const record = await app.sdk.create('task', recordData);
78
82
  app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
79
83
  }),
@@ -81,9 +85,13 @@ export function ticketsCmd(wrap) {
81
85
  .command({
82
86
  command: 'update <number>',
83
87
  describe: 'Update a ticket',
84
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
88
+ builder: (y) => y
89
+ .option('data', { type: 'string', describe: 'JSON fields' })
90
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
91
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
92
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
85
93
  handler: wrap(async (argv, app) => {
86
- const recordData = JSON.parse(argv.data);
94
+ const recordData = buildRecordPayload(argv);
87
95
  const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
88
96
  const updated = await app.sdk.update('task', sysID, recordData);
89
97
  app.ok(updated, { summary: `Updated ticket ${argv.number}` });
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
2
2
  import { resolveRecord, resolveSysId } from '../resolve-record.js';
3
3
  import { declareCapabilities } from '../capabilities.js';
4
4
 
@@ -72,12 +72,12 @@ export function usersCmd(wrap) {
72
72
  .option('username', { alias: 'u', type: 'string', describe: 'Username (e.g. john.doe)' })
73
73
  .option('name', { alias: 'n', type: 'string', describe: 'Full name (e.g. John Doe)' })
74
74
  .option('email', { alias: 'e', type: 'string', describe: 'Email address' })
75
- .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
75
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' })
76
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
77
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
78
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
76
79
  handler: wrap(async (argv, app) => {
77
- const recordData = {};
78
- if (argv.data) {
79
- Object.assign(recordData, JSON.parse(argv.data));
80
- }
80
+ const recordData = buildRecordPayload(argv, { required: false });
81
81
  if (argv.username) recordData.user_name = argv.username;
82
82
  if (argv.name) recordData.name = argv.name;
83
83
  if (argv.email) recordData.email = argv.email;
@@ -97,10 +97,13 @@ export function usersCmd(wrap) {
97
97
  command: 'update <identifier>',
98
98
  describe: 'Update a user by user_name or sys_id',
99
99
  builder: (y) => y
100
- .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
100
+ .option('data', { type: 'string', describe: 'JSON data to update' })
101
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
102
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
103
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
101
104
  handler: wrap(async (argv, app) => {
102
105
  const id = argv.identifier;
103
- const recordData = JSON.parse(argv.data);
106
+ const recordData = buildRecordPayload(argv);
104
107
  const sysID = await resolveSysId(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
105
108
  const updated = await app.sdk.update('sys_user', sysID, recordData);
106
109
  app.ok(updated, {
package/src/helpers.js CHANGED
@@ -522,6 +522,50 @@ export function parseDataArg(argv) {
522
522
  }
523
523
  }
524
524
 
525
+ /**
526
+ * Parse repeatable -f/--field name=value options.
527
+ * Values remain strings, including empty values and values containing '='.
528
+ */
529
+ export function parseFieldPairs(argv = {}) {
530
+ const raw = argv.field == null ? [] : (Array.isArray(argv.field) ? argv.field : [argv.field]);
531
+ const result = {};
532
+ for (const token of raw) {
533
+ const text = String(token);
534
+ const equals = text.indexOf('=');
535
+ if (equals < 0) throw new Error(`-f expects field=value, got "${text}"`);
536
+ const name = text.slice(0, equals);
537
+ let value = text.slice(equals + 1);
538
+ if (!name || /\\s/.test(name) || !/^[A-Za-z0-9_.]+$/.test(name)) {
539
+ throw new Error(`-f expects a valid field name, got "${name}"`);
540
+ }
541
+ if (value.startsWith('@@')) {
542
+ value = value.slice(1);
543
+ } else if (value.startsWith('@')) {
544
+ value = fs.readFileSync(value.slice(1), 'utf-8');
545
+ if (value.charCodeAt(0) === 0xFEFF) value = value.slice(1);
546
+ }
547
+ result[name] = value;
548
+ }
549
+ return result;
550
+ }
551
+
552
+ /**
553
+ * Build a record mutation payload from JSON input and/or field pairs.
554
+ */
555
+ export function buildRecordPayload(argv = {}, { required = true } = {}) {
556
+ const hasData = Boolean(argv.data || argv['data-file'] || argv['data-stdin']);
557
+ const hasFields = argv.field != null && (Array.isArray(argv.field) ? argv.field.length > 0 : true);
558
+ if (!hasData && !hasFields) {
559
+ if (required) throw new Error('Provide fields with -f name=value, or a JSON payload with --data/--data-file/--data-stdin');
560
+ return {};
561
+ }
562
+ const payload = hasData ? parseDataArg(argv) : {};
563
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
564
+ throw new Error('Record payload must be a JSON object');
565
+ }
566
+ return { ...payload, ...parseFieldPairs(argv) };
567
+ }
568
+
525
569
  /**
526
570
  * Translate human-readable type names to ServiceNow item_option_new type IDs.
527
571
  * Maps common names like "date", "select", "multilinetext" to their integer IDs.