@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 +1 -1
- package/src/cli.js +3 -1
- package/src/commands/attachments.js +88 -0
- package/src/commands/records.js +1 -71
- package/src/mutations.js +1 -1
package/package.json
CHANGED
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 (
|
|
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
|
+
});
|
package/src/commands/records.js
CHANGED
|
@@ -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'
|
|
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
|
-
* `
|
|
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
|