@jacebenson/jsn 4.3.4 → 4.3.5-dev.6

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.4",
3
+ "version": "4.3.5-dev.6",
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": {
@@ -23,6 +23,7 @@
23
23
  "automation"
24
24
  ],
25
25
  "author": "Jace Benson",
26
+ "repository": "https://github.com/jacebenson/jsn",
26
27
  "license": "MIT",
27
28
  "engines": {
28
29
  "node": ">=22.5.0"
@@ -1,3 +1,4 @@
1
+ import { createPrompt, useKeypress, usePagination, useState, isDownKey, isEnterKey, isUpKey } from '@inquirer/core';
1
2
  import { loginWizard, modifyProfile, removeProfile, pickProfile } from './auth.js';
2
3
  import { setActiveProfile } from '../config.js';
3
4
  import { errUsage } from '../errors.js';
@@ -7,18 +8,44 @@ import { declareCapabilities } from '../capabilities.js';
7
8
  // (Not in the daily-check skip-list: legacy behavior checks for updates here.)
8
9
  declareCapabilities('setup', { noInstance: true });
9
10
 
10
- /** Interactive hub: what do you want to do? Returns 'add' | 'switch' | 'remove' | 'modify'. */
11
- export async function authHubMenu() {
12
- const { select } = await import('@inquirer/prompts');
13
- return select({
14
- message: 'What would you like to do?',
15
- choices: [
16
- { name: 'Add a new instance', value: 'add' },
17
- { name: 'Switch to a different instance', value: 'switch' },
18
- { name: 'Remove an instance', value: 'remove' },
19
- { name: 'Modify an instance', value: 'modify' },
20
- ],
11
+ const HUB_CHOICES = [
12
+ { name: 'Add a new instance', value: 'add' },
13
+ { name: 'Switch to a different instance', value: 'switch' },
14
+ { name: 'Remove an instance', value: 'remove' },
15
+ { name: 'Modify an instance', value: 'modify' },
16
+ ];
17
+
18
+ /** The setup hub prompt; q and Escape resolve with no selected action. */
19
+ export const authHubPrompt = createPrompt((config, done) => {
20
+ const [active, setActive] = useState(0);
21
+ const choices = config.choices;
22
+
23
+ useKeypress((key) => {
24
+ if (key.name === 'q' || key.name === 'escape') {
25
+ done(undefined);
26
+ return;
27
+ }
28
+ if (isEnterKey(key)) {
29
+ done(choices[active].value);
30
+ return;
31
+ }
32
+ if (isUpKey(key) && active > 0) setActive(active - 1);
33
+ if (isDownKey(key) && active < choices.length - 1) setActive(active + 1);
21
34
  });
35
+
36
+ const page = usePagination({
37
+ items: choices,
38
+ active,
39
+ pageSize: choices.length,
40
+ loop: false,
41
+ renderItem: ({ item, isActive }) => `${isActive ? '❯' : ' '} ${item.name}`,
42
+ });
43
+ return `${config.message}\n${page}\n\n↑↓ navigate • ⏎ select • q/Esc exit`;
44
+ });
45
+
46
+ /** Interactive hub: what do you want to do? Returns an action or undefined on exit. */
47
+ export async function authHubMenu({ promptFn = authHubPrompt, input, output } = {}) {
48
+ return promptFn({ message: 'What would you like to do?', choices: HUB_CHOICES }, { input, output });
22
49
  }
23
50
 
24
51
  /**
@@ -36,7 +63,7 @@ export async function dispatchSetupAction(app, argv, action) {
36
63
  } else if (action === 'remove') {
37
64
  const name = await pickProfile(app, 'Remove which instance?');
38
65
  await removeProfile(app, name);
39
- } else { // modify
66
+ } else if (action === 'modify') {
40
67
  await modifyProfile(app, argv);
41
68
  }
42
69
  }
@@ -60,6 +87,7 @@ export function setupCmd(wrap) {
60
87
  return;
61
88
  }
62
89
  const action = await authHubMenu();
90
+ if (action === undefined) return;
63
91
  await dispatchSetupAction(app, argv, action);
64
92
  }),
65
93
  };
package/src/sdk.js CHANGED
@@ -266,6 +266,8 @@ export class SDKClient {
266
266
 
267
267
  /**
268
268
  * Upload a file as a new attachment on a record.
269
+ * Uses ServiceNow's binary attachment endpoint so file bytes are not expanded
270
+ * into base64 or embedded in a sys.scripts.do form body.
269
271
  * @param {string} table - parent table name (e.g. 'incident')
270
272
  * @param {string} sysID - parent record sys_id
271
273
  * @param {Buffer|string} content - file bytes or path
@@ -274,29 +276,30 @@ export class SDKClient {
274
276
  */
275
277
  async addAttachment(table, sysID, content, fileName) {
276
278
  const contentBuffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content));
277
- const script = [
278
- '(function() {',
279
- ` var record = new GlideRecord(${scriptString(table)});`,
280
- ` if (!record.get(${scriptString(sysID)})) throw new Error('Record not found');`,
281
- ' var attachment = new GlideSysAttachment();',
282
- ` var fileName = ${scriptString(fileName)};`,
283
- ` var content = ${scriptString(contentBuffer.toString('base64'))};`,
284
- ' var sysId;',
285
- " if (typeof attachment.writeBase64 === 'function') {",
286
- " sysId = attachment.writeBase64(record, fileName, 'application/octet-stream', content);",
287
- ' } else {',
288
- " sysId = new Attachment().write(record.getTableName(), record.getUniqueValue(), fileName, 'application/octet-stream', GlideStringUtil.base64DecodeAsBytes(content));",
289
- ' }',
290
- " if (!sysId) throw new Error('Attachment was not created');",
291
- " gs.print('JSN_ATTACHMENT_RESULT:' + JSON.stringify({ sys_id: sysId }));",
292
- '}());',
293
- ].join('\n');
294
- const output = await this.executeScript(script, '');
295
- const result = parseAttachmentResult(output);
296
- if (!result?.sys_id) {
297
- throw new Error('Attachment upload returned no attachment sys_id');
279
+ const params = new URLSearchParams({
280
+ table_name: String(table),
281
+ table_sys_id: String(sysID),
282
+ file_name: String(fileName),
283
+ });
284
+ const endpoint = `${this.baseURL}/api/now/attachment/file?${params.toString()}`;
285
+
286
+ try {
287
+ const response = await this.request(endpoint, {
288
+ method: 'POST',
289
+ headers: { 'Content-Type': 'application/octet-stream' },
290
+ body: contentBuffer,
291
+ });
292
+ const result = response?.result;
293
+ if (!result?.sys_id) {
294
+ throw new Error('Attachment upload returned no attachment sys_id');
295
+ }
296
+ return result;
297
+ } catch (err) {
298
+ if (err?.status === 413) {
299
+ throw errAPI(413, 'Attachment is too large for the ServiceNow attachment endpoint. Reduce the file size or increase the instance attachment limit.');
300
+ }
301
+ throw err;
298
302
  }
299
- return result;
300
303
  }
301
304
 
302
305
  async getCurrentUser({ touchLastSeen = true } = {}) {
@@ -921,27 +924,6 @@ export class SDKClient {
921
924
  }
922
925
  }
923
926
 
924
- function scriptString(value) {
925
- return JSON.stringify(String(value)).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
926
- }
927
-
928
- function parseAttachmentResult(output) {
929
- if (!output) return null;
930
- const lines = String(output).split(/\r?\n/).reverse();
931
- for (const line of lines) {
932
- const candidate = line.replace(/^\*\*\* Script:\s*/, '').trim();
933
- const markerCandidate = candidate.replace(/^JSN_ATTACHMENT_RESULT:\s*/, '');
934
- if (!markerCandidate.startsWith('{')) continue;
935
- try {
936
- const result = JSON.parse(markerCandidate);
937
- if (result && typeof result === 'object') return result;
938
- } catch {
939
- // Ignore unrelated script output and keep looking for the result marker.
940
- }
941
- }
942
- return null;
943
- }
944
-
945
927
  function detectScriptError(output) {
946
928
  const lines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
947
929
  const errorLine = lines.find(line => /(?:ReferenceError|TypeError|RhinoEcmaError|Script execution error|undefined is not a function|is not defined)/i.test(line));