@jacebenson/jsn 4.3.13-dev.91.1 → 4.3.13-dev.93.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.91.1",
3
+ "version": "4.3.13-dev.93.1",
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": {
package/src/cli.js CHANGED
@@ -19,6 +19,7 @@ import { discoverCmd } from './commands/discover.js';
19
19
  import { setupCmd } from './commands/setup.js';
20
20
  import { authCmd } from './commands/auth.js';
21
21
  import { recordsCmd } from './commands/records.js';
22
+ import { attachmentsCmd } from './commands/attachments.js';
22
23
  import { cmdbCmd } from './commands/cmdb.js';
23
24
  import { snippetsCmd } from './commands/snippets.js';
24
25
  import { catalogCmd } from './commands/catalog.js';
@@ -409,6 +410,7 @@ export function buildCLI() {
409
410
  // DATA
410
411
  .command(cmdbCmd(wrap))
411
412
  .command(recordsCmd(wrap))
413
+ .command(attachmentsCmd(wrap))
412
414
  .command(transactionsCmd(wrap))
413
415
 
414
416
  .command(platformCmd(wrap))
@@ -486,7 +488,7 @@ export function buildCLI() {
486
488
  })
487
489
  .onParentDiscovery((argv, { path, subcommands }) => {
488
490
  // Reached only for a parent command that declares no default handler of
489
- // its own (records attachments, docs community, and any future parent) —
491
+ // its own (attachments, docs community, and any future parent) —
490
492
  // the adapter used to fall through and exit 0 having printed nothing.
491
493
  // Children come from the registry, description included, so this list
492
494
  // cannot drift from the registrations the way a hand-written one did.
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { confirmDelete, getStringField } from '../helpers.js';
4
+ import { errUsage } from '../errors.js';
5
+ import { declareCapabilities } from '../capabilities.js';
6
+
7
+ declareCapabilities('attachments', { mutationSubcommands: ['upload', 'add'] });
8
+ const identifier = value => {
9
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) throw errUsage('Supply a safe record or attachment sys_id.');
10
+ return value;
11
+ };
12
+
13
+ // A server-provided filename may never choose directories or platform devices.
14
+ export function attachmentFilename(value, id) {
15
+ const name = path.posix.basename(String(value || '').replaceAll('\\', '/'))
16
+ .replace(/[<>:"|?*\p{Cc}]/gu, '_').replace(/[. ]+$/g, '');
17
+ return !name || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name) ? `${id}.bin` : name;
18
+ }
19
+
20
+ export const attachmentsCmd = wrap => ({
21
+ command: 'attachments [subcommand]',
22
+ aliases: ['attachment'],
23
+ describe: 'List, download and upload files attached to any record',
24
+ builder: y => y
25
+ .epilogue([
26
+ 'List files: jsn attachments list --sys-id <record-id>',
27
+ 'Download by attachment ID: jsn attachments download <attachment-id> --out ./file.bin',
28
+ 'Upload: jsn attachments upload ./file.txt --table incident --sys-id <record-id>',
29
+ 'download/get and upload/add are aliases. The former records attachments subtree has moved here.',
30
+ 'Downloads use a safe basename and refuse to overwrite existing local files unless --overwrite is supplied.',
31
+ ].join('\n'))
32
+ .command({
33
+ command: 'list',
34
+ describe: 'List attachment metadata for a parent record',
35
+ builder: y => y
36
+ .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
37
+ .option('table', { type: 'string', describe: 'Parent table (output context only)' }),
38
+ handler: wrap(async (argv, app) => {
39
+ const id = identifier(argv['sys-id']);
40
+ app.requireInstance();
41
+ const attachments = await app.sdk.listAttachments(id);
42
+ app.ok({ sys_id: id, table: argv.table || '', count: attachments.length, attachments,
43
+ context: { instance_url: app.getEffectiveInstance() } }, { summary: `${attachments.length} attachment(s) on record ${id}` });
44
+ }),
45
+ })
46
+ .command({
47
+ command: 'download <attachment-id>',
48
+ aliases: ['get'],
49
+ describe: 'Download file bytes by attachment ID; no parent record ID needed',
50
+ builder: y => y
51
+ .positional('attachment-id', { type: 'string', describe: 'Attachment sys_id' })
52
+ .option('out', { alias: 'o', type: 'string', describe: 'Local output path (default: safe attachment filename in cwd)' })
53
+ .option('overwrite', { type: 'boolean', default: false, describe: 'Replace an existing local file' }),
54
+ handler: wrap(async (argv, app) => {
55
+ const id = identifier(argv.attachmentId ?? argv['attachment-id']);
56
+ app.requireInstance();
57
+ let out = argv.out;
58
+ if (!out) {
59
+ const metadata = await app.sdk.request(`${app.sdk.baseURL}/api/now/attachment/${id}`, { method: 'GET' });
60
+ out = attachmentFilename(getStringField(metadata?.result, 'file_name'), id);
61
+ }
62
+ const bytes = await app.sdk.getAttachment(id);
63
+ await fs.writeFile(out, bytes, { flag: argv.overwrite ? 'w' : 'wx' });
64
+ app.ok({ sys_id: id, out, bytes: bytes.length }, { summary: `Wrote ${bytes.length} bytes to ${out}` });
65
+ }),
66
+ })
67
+ .command({
68
+ command: 'upload <file>',
69
+ aliases: ['add'],
70
+ describe: 'Upload a local file to a record (requires confirmation)',
71
+ builder: y => y
72
+ .positional('file', { type: 'string', describe: 'Local file to upload' })
73
+ .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
74
+ .option('table', { type: 'string', demandOption: true, describe: 'Parent table, for example incident' })
75
+ .option('name', { type: 'string', describe: 'Attachment filename (default: local basename)' })
76
+ .option('force', { type: 'boolean', default: false, describe: 'Skip upload confirmation after approval' }),
77
+ handler: wrap(async (argv, app) => {
78
+ const id = identifier(argv['sys-id']);
79
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(argv.table)) throw errUsage('Supply a valid parent table name.');
80
+ app.requireInstance();
81
+ await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${id}?`);
82
+ const content = await fs.readFile(argv.file);
83
+ const fileName = argv.name || path.basename(argv.file);
84
+ const created = await app.sdk.addAttachment(argv.table, id, content, fileName);
85
+ app.ok({ sys_id: getStringField(created, 'sys_id'), file_name: fileName, table: argv.table, recordsys_id: id }, { summary: `Attached ${fileName}` });
86
+ }),
87
+ }),
88
+ });
@@ -1,5 +1,3 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, verifyWriteBack } from '../helpers.js';
4
2
  import { listRecords, recordListParams, getRecord, createRecord, updateRecord, deleteRecord } from '../records-engine.js';
5
3
  import { unwrapSysId } from '../resolve-record.js';
@@ -7,7 +5,7 @@ import { errUsage } from '../errors.js';
7
5
  import { declareCapabilities } from '../capabilities.js';
8
6
 
9
7
  declareCapabilities('records', {
10
- mutationSubcommands: ['create', 'update', 'delete', 'bulk', 'attachments add'],
8
+ mutationSubcommands: ['create', 'update', 'delete', 'bulk'],
11
9
  });
12
10
 
13
11
  const tableDefaultColumns = {
@@ -304,74 +302,6 @@ export function recordsCmd(wrap) {
304
302
  app.ok({ message: 'Record deleted', table: argv.table, sys_id: sysID }, { summary: `Deleted record from ${argv.table}` });
305
303
  }),
306
304
  })
307
- .command({
308
- command: 'attachments',
309
- describe: 'Work with attachments on a record',
310
- builder: (y) => y
311
- .command({
312
- command: 'list',
313
- describe: 'List attachments on a record',
314
- builder: (yy) => yy
315
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
316
- .option('table', { type: 'string', describe: 'Parent table name (context only)' }),
317
- handler: wrap(async (argv, app) => {
318
- app.requireInstance();
319
- const attachments = await app.sdk.listAttachments(argv['sys-id']);
320
- app.ok({
321
- sys_id: argv['sys-id'],
322
- table: argv.table || '',
323
- count: attachments.length,
324
- attachments,
325
- context: { instance_url: app.getEffectiveInstance() },
326
- }, { summary: `${attachments.length} attachment(s) on record ${argv['sys-id']}` });
327
- }),
328
- })
329
- .command({
330
- command: 'get <attachment-id>',
331
- describe: 'Download an attachment to a local file',
332
- builder: (yy) => yy
333
- .positional('attachment-id', { describe: 'Attachment sys_id', type: 'string' })
334
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id (used to resolve the file name)' })
335
- .option('out', { alias: 'o', type: 'string', describe: 'Output file path (default: attachment file name in cwd)' }),
336
- handler: wrap(async (argv, app) => {
337
- app.requireInstance();
338
- const buf = await app.sdk.getAttachment(argv['attachment-id']);
339
- let name = argv.out;
340
- if (!name) {
341
- // Best-effort: derive the file name from the attachment row.
342
- let fileName = `${argv['attachment-id']}.bin`;
343
- try {
344
- const rows = await app.sdk.listAttachments(argv['sys-id']);
345
- const hit = (rows || []).find((a) => getStringField(a, 'sys_id') === argv['attachment-id']);
346
- const fn = getStringField(hit, 'file_name');
347
- if (fn) fileName = fn;
348
- } catch { /* keep default */ }
349
- name = fileName;
350
- }
351
- await fs.promises.writeFile(name, buf);
352
- app.ok({ sys_id: argv['attachment-id'], out: name, bytes: buf.length }, { summary: `Wrote ${buf.length} bytes to ${name}` });
353
- }),
354
- })
355
- .command({
356
- command: 'add <file>',
357
- describe: 'Upload a file as a new attachment on a record (mutation)',
358
- builder: (yy) => yy
359
- .positional('file', { describe: 'Local file path to upload', type: 'string' })
360
- .option('sys-id', { alias: 's', type: 'string', demandOption: true, describe: 'Parent record sys_id' })
361
- .option('table', { type: 'string', demandOption: true, describe: 'Parent table for the record (e.g. incident)' })
362
- .option('name', { type: 'string', describe: 'Display file name (default: basename of --file)' })
363
- .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
364
- handler: wrap(async (argv, app) => {
365
- app.requireInstance();
366
- await confirmDelete(app, argv, `Upload "${argv.file}" as an attachment on ${argv.table} ${argv['sys-id']}?`);
367
- const content = await fs.promises.readFile(argv.file);
368
- const fileName = argv.name || path.basename(argv.file);
369
- const created = await app.sdk.addAttachment(argv.table, argv['sys-id'], content, fileName);
370
- app.ok({ sys_id: created?.sys_id, file_name: fileName, table: argv.table, recordsys_id: argv['sys-id'] }, { summary: `Attached ${fileName}` });
371
- }),
372
- })
373
- .demandCommand(1, 'Specify an attachment action: list, get, or add'),
374
- })
375
305
  .command({
376
306
  command: 'aggregate',
377
307
  describe: 'Run ServiceNow Stats API aggregation with optional grouping',
package/src/mutations.js CHANGED
@@ -33,7 +33,7 @@ function currentPaths(paths) {
33
33
  * Prefix match: a command whose argv._ path begins with a mutation pattern
34
34
  * IS that mutation (trailing positionals like a sys_id or file path don't
35
35
  * change the intent). This lets positional mutation subcommands (e.g.
36
- * `records attachments <id> add <file>`) be gated on read-only profiles.
36
+ * `attachments upload <file>`) be gated on read-only profiles.
37
37
  *
38
38
  * @param {object} argv — yargs parsed argv with `_` array
39
39
  * @param {Array<string[]>} [paths] — mutation path patterns; defaults to