@jacebenson/jsn 4.3.13-dev.92.1 → 4.3.13-dev.94.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/README.md +14 -0
- package/package.json +1 -1
- package/src/cli-adapter.js +4 -0
- package/src/cli.js +20 -4
- package/src/command-audience.js +35 -0
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +15 -0
- package/src/commands/discover.js +1 -1
- package/src/commands/records.js +1 -71
- package/src/help.js +2 -0
- package/src/mutations.js +1 -1
package/README.md
CHANGED
|
@@ -78,3 +78,17 @@ npm test
|
|
|
78
78
|
## License
|
|
79
79
|
|
|
80
80
|
MIT
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### Commands for the connected account
|
|
84
|
+
|
|
85
|
+
Login and OAuth refresh check for the current account's `admin` role. Profiles
|
|
86
|
+
without that role show a compact command list focused on tickets, generic
|
|
87
|
+
records, people/groups, documentation, and connection management. If the role
|
|
88
|
+
check is unavailable, the profile also uses compact help; this does not assert
|
|
89
|
+
that the account lacks admin. Existing profiles keep full help until renewed.
|
|
90
|
+
|
|
91
|
+
This applies to root help, root JSON, `discover`, and root shell completion.
|
|
92
|
+
Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
|
|
93
|
+
every registered command. Explicit command paths remain available and ServiceNow
|
|
94
|
+
ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
|
package/package.json
CHANGED
package/src/cli-adapter.js
CHANGED
|
@@ -332,6 +332,10 @@ export class LocalCLI {
|
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
_help(node, path = []) {
|
|
335
|
+
if (!path.length && this.rootHelpRenderer) {
|
|
336
|
+
process.stdout.write(this.rootHelpRenderer() + "\n");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
335
339
|
const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
|
|
336
340
|
if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
|
|
337
341
|
if (node.children.length) lines.push('\nCommands:\n' + node.children.filter((n) => n.name !== this.completionCommand).map((n) => ` ${n.definition.command} ${n.definition.describe || ''}`).join('\n'));
|
package/src/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { visibleCommandRegistry } from './command-audience.js';
|
|
1
2
|
// Root CLI using the local yargs-compatible adapter
|
|
2
3
|
|
|
3
4
|
import yargs, { hideBin } from './cli-adapter.js';
|
|
@@ -19,6 +20,7 @@ import { discoverCmd } from './commands/discover.js';
|
|
|
19
20
|
import { setupCmd } from './commands/setup.js';
|
|
20
21
|
import { authCmd } from './commands/auth.js';
|
|
21
22
|
import { recordsCmd } from './commands/records.js';
|
|
23
|
+
import { attachmentsCmd } from './commands/attachments.js';
|
|
22
24
|
import { cmdbCmd } from './commands/cmdb.js';
|
|
23
25
|
import { snippetsCmd } from './commands/snippets.js';
|
|
24
26
|
import { catalogCmd } from './commands/catalog.js';
|
|
@@ -147,6 +149,7 @@ export function buildCLI() {
|
|
|
147
149
|
},
|
|
148
150
|
])
|
|
149
151
|
.options({
|
|
152
|
+
'all-commands': { type: 'boolean', describe: 'Show the full command list, including admin and developer tools' },
|
|
150
153
|
reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
|
|
151
154
|
|
|
152
155
|
force: {
|
|
@@ -409,6 +412,7 @@ export function buildCLI() {
|
|
|
409
412
|
// DATA
|
|
410
413
|
.command(cmdbCmd(wrap))
|
|
411
414
|
.command(recordsCmd(wrap))
|
|
415
|
+
.command(attachmentsCmd(wrap))
|
|
412
416
|
.command(transactionsCmd(wrap))
|
|
413
417
|
|
|
414
418
|
.command(platformCmd(wrap))
|
|
@@ -430,7 +434,7 @@ export function buildCLI() {
|
|
|
430
434
|
.command(skillCmd(wrap))
|
|
431
435
|
.command(docsCmd(wrap))
|
|
432
436
|
.command(versionCmd(wrap))
|
|
433
|
-
.command(discoverCmd(wrap, () => cliInstance.commandRegistry()))
|
|
437
|
+
.command(discoverCmd(wrap, (argv) => visibleCommandRegistry(cliInstance.commandRegistry(), app.config, argv)))
|
|
434
438
|
.demandCommand(1, 'You must specify a command')
|
|
435
439
|
// Completion plumbing under a hidden command name, so the explicit
|
|
436
440
|
// 'completion' command below owns the UX. (#176)
|
|
@@ -450,6 +454,12 @@ export function buildCLI() {
|
|
|
450
454
|
if (current && !current.startsWith('-') && depth === 0) {
|
|
451
455
|
out = [...new Set([...out, ...rootCommands])];
|
|
452
456
|
}
|
|
457
|
+
const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
|
|
458
|
+
const visible = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(options), options);
|
|
459
|
+
if (!depth) {
|
|
460
|
+
const names = new Set(visible.commands.flatMap(command => [command.name, ...command.aliases]));
|
|
461
|
+
out = out.filter(value => value.startsWith('-') || names.has(value));
|
|
462
|
+
}
|
|
453
463
|
done([...new Set(out)]);
|
|
454
464
|
});
|
|
455
465
|
})
|
|
@@ -486,7 +496,7 @@ export function buildCLI() {
|
|
|
486
496
|
})
|
|
487
497
|
.onParentDiscovery((argv, { path, subcommands }) => {
|
|
488
498
|
// Reached only for a parent command that declares no default handler of
|
|
489
|
-
// its own (
|
|
499
|
+
// its own (attachments, docs community, and any future parent) —
|
|
490
500
|
// the adapter used to fall through and exit 0 having printed nothing.
|
|
491
501
|
// Children come from the registry, description included, so this list
|
|
492
502
|
// cannot drift from the registrations the way a hand-written one did.
|
|
@@ -555,7 +565,7 @@ export function buildCLI() {
|
|
|
555
565
|
if (typeof formatArgv?.get === 'string' && formatArgv.get) {
|
|
556
566
|
app.output.setJqFilter(formatArgv.get);
|
|
557
567
|
}
|
|
558
|
-
const { commands } = cliInstance.commandRegistry();
|
|
568
|
+
const { commands } = visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv);
|
|
559
569
|
try {
|
|
560
570
|
app.ok({ commands }, {
|
|
561
571
|
summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
|
|
@@ -565,7 +575,7 @@ export function buildCLI() {
|
|
|
565
575
|
}
|
|
566
576
|
process.exit(0);
|
|
567
577
|
}
|
|
568
|
-
process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
|
|
578
|
+
process.stdout.write(renderHelp(loadConfig(formatArgv), visibleCommandRegistry(cliInstance.commandRegistry(), loadConfig(formatArgv), formatArgv)) + '\n');
|
|
569
579
|
process.exit(0);
|
|
570
580
|
}
|
|
571
581
|
// Every other failure here (unknown command, missing required argument,
|
|
@@ -585,6 +595,12 @@ export function buildCLI() {
|
|
|
585
595
|
exitWithError(new AppError(CodeUsage, msg, hint), { app });
|
|
586
596
|
});
|
|
587
597
|
|
|
598
|
+
cliInstance.rootHelpRenderer = () => {
|
|
599
|
+
const options = cliInstance.parseGlobalOptions(process.argv.slice(2));
|
|
600
|
+
const config = loadConfig(options);
|
|
601
|
+
return renderHelp(config, visibleCommandRegistry(cliInstance.commandRegistry(), config, options));
|
|
602
|
+
};
|
|
603
|
+
|
|
588
604
|
// Snapshot the root command names (plus aliases) for the completion
|
|
589
605
|
// filter. Done before any parse — command builders mutate the registry.
|
|
590
606
|
rootCommands = cliInstance.getInternalMethods().getCommandInstance().getCommands();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Presentation policy only. ServiceNow ACLs still authorize every request.
|
|
2
|
+
// Explicit command paths and --all-commands remain available to every account.
|
|
3
|
+
const BASIC_COMMANDS = new Set([
|
|
4
|
+
'incidents', 'changes', 'requests', 'tasks', 'tickets', 'approvals', 'records',
|
|
5
|
+
'groups', 'groupmembers', 'grouproles', 'users', 'setup', 'auth', 'skill',
|
|
6
|
+
'version', 'docs', 'history', 'discover', 'completion',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export async function detectCommandAudience(sdk, username, instanceURL = sdk?.baseURL) {
|
|
10
|
+
if (!username || typeof sdk?.listStrict !== 'function') return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const roles = await sdk.listStrict('sys_user_has_role', {
|
|
13
|
+
sysparm_query: 'user=javascript:gs.getUserID()^role.name=admin',
|
|
14
|
+
sysparm_fields: 'sys_id', sysparm_limit: '1',
|
|
15
|
+
}, { touchLastSeen: false });
|
|
16
|
+
return { username, instance_url: instanceURL, mode: roles.length ? 'admin' : 'basic', role_check: 'verified' };
|
|
17
|
+
} catch {
|
|
18
|
+
// Missing role access must not expand help. This is a presentation fallback,
|
|
19
|
+
// not a claim that the role query succeeded or an authorization boundary.
|
|
20
|
+
return { username, instance_url: instanceURL, mode: 'basic', role_check: 'unavailable' };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function commandAudience(config, { instance, allCommands = false } = {}) {
|
|
25
|
+
if (allCommands || instance) return 'all';
|
|
26
|
+
const name = config?.activeProfile || config?.defaultProfile;
|
|
27
|
+
const profile = config?.profiles?.[name];
|
|
28
|
+
const cached = profile?.command_audience;
|
|
29
|
+
return cached?.username === profile?.username && cached?.instance_url === profile?.instance_url && cached?.mode === 'basic' ? 'basic' : 'all';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function visibleCommandRegistry(registry, config, options = {}) {
|
|
33
|
+
if (commandAudience(config, options) !== 'basic') return registry;
|
|
34
|
+
return { ...registry, commands: registry.commands.filter(command => BASIC_COMMANDS.has(command.name)) };
|
|
35
|
+
}
|
|
@@ -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/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { detectCommandAudience } from '../command-audience.js';
|
|
1
2
|
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
|
|
2
3
|
import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
|
|
3
4
|
import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
|
|
@@ -710,6 +711,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
710
711
|
// profile exists; preserve it when verification is unavailable.
|
|
711
712
|
username = username || targetUsername;
|
|
712
713
|
|
|
714
|
+
const commandAudience = await detectCommandAudience(app.sdk, username, instanceURL);
|
|
715
|
+
|
|
713
716
|
// Save to profiles — deduplicate by instance URL
|
|
714
717
|
if (!app.config.profiles) {
|
|
715
718
|
app.config.profiles = {};
|
|
@@ -740,7 +743,9 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
740
743
|
read_only: preferences.read_only,
|
|
741
744
|
skip_confirmations: preferences.skip_confirmations,
|
|
742
745
|
include_counts: preferences.include_counts,
|
|
746
|
+
...(commandAudience ? { command_audience: commandAudience } : {}),
|
|
743
747
|
};
|
|
748
|
+
if (!commandAudience) delete app.config.profiles[profileName].command_audience;
|
|
744
749
|
if (!targetProfile && interactive) {
|
|
745
750
|
try {
|
|
746
751
|
const domains = await import('./domains.js');
|
|
@@ -1050,12 +1055,22 @@ Examples:
|
|
|
1050
1055
|
const name = target.name || app.config.activeProfile || app.config.defaultProfile;
|
|
1051
1056
|
if (name && app.config.profiles[name] && app.config.profiles[name].instance_url === instanceURL) {
|
|
1052
1057
|
app.config.profiles[name].domain_separation = hasDS;
|
|
1058
|
+
|
|
1053
1059
|
await saveConfig(app.config);
|
|
1054
1060
|
}
|
|
1055
1061
|
} catch {
|
|
1056
1062
|
// Non-fatal — capability probe is best-effort
|
|
1057
1063
|
}
|
|
1058
1064
|
|
|
1065
|
+
const audienceName = target.name || app.config.activeProfile || app.config.defaultProfile;
|
|
1066
|
+
const audienceProfile = app.config.profiles?.[audienceName];
|
|
1067
|
+
if (audienceProfile?.instance_url === instanceURL) {
|
|
1068
|
+
const sdk = app.getSDKForProfile
|
|
1069
|
+
? app.getSDKForProfile(instanceURL, { ...targetAuthOptions, profile: audienceProfile }) : app.sdk;
|
|
1070
|
+
audienceProfile.command_audience = await detectCommandAudience(sdk, audienceProfile.username, instanceURL);
|
|
1071
|
+
await saveConfig(app.config);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1059
1074
|
app.ok({
|
|
1060
1075
|
refreshed: true,
|
|
1061
1076
|
instance: instanceURL,
|
package/src/commands/discover.js
CHANGED
|
@@ -35,7 +35,7 @@ export function discoverCmd(wrap, getRegistry) {
|
|
|
35
35
|
'Use --json for structured results or --json --get data.commands for only matches.',
|
|
36
36
|
].join('\n')),
|
|
37
37
|
handler: wrap(async (argv, app) => {
|
|
38
|
-
const commands = discoverCommands(getRegistry(), { query: argv.query, table: argv.table });
|
|
38
|
+
const commands = discoverCommands(getRegistry(argv), { query: argv.query, table: argv.table });
|
|
39
39
|
const lines = commands.flatMap(command => [
|
|
40
40
|
`${command.name}: ${command.description}`,
|
|
41
41
|
...(command.discovery ? [
|
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/help.js
CHANGED
|
@@ -203,6 +203,8 @@ function renderFlags(options) {
|
|
|
203
203
|
export function renderTips() {
|
|
204
204
|
return `
|
|
205
205
|
TIPS
|
|
206
|
+
Show every command, including admin and developer tools: "jsn --all-commands --help"
|
|
207
|
+
Command visibility follows the selected profile at authentication; explicit commands still use ServiceNow ACLs.
|
|
206
208
|
Find commands by task words: "jsn discover catalog"; by configured table: "jsn discover --table incident"
|
|
207
209
|
Cross-table task searches and custom tables: "jsn records list --help"
|
|
208
210
|
Specialized commands offer configured columns, lookup, and table-specific views; inspect discovery results for details
|
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
|