@jacebenson/jsn 4.3.7-dev.13 → 4.3.7-dev.15

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 CHANGED
@@ -81,6 +81,10 @@ jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b p
81
81
  jsn eval "gs.info('Hello World')"
82
82
  cat script.js | jsn eval --stdin # pipe a script in (no shell escaping)
83
83
 
84
+ # Now GraphQL (documents may contain mutations; read-only profiles are blocked)
85
+ jsn graphql --query '{ GlideRecord_Query { incident(pagination: {limit: 2}) { _results { number { value } } } } }'
86
+ cat incident.graphql | jsn graphql --stdin --variables '{"limit":2}'
87
+
84
88
  # Saved query snippets (stored locally, run against the active profile)
85
89
  jsn snippets save open-inc --table incident --query "active=true"
86
90
  jsn snippets run open-inc
@@ -167,6 +171,10 @@ export SERVICENOW_OAUTH_TOKEN="***"
167
171
  jsn incidents list
168
172
  ```
169
173
 
174
+ For the credential identity, auth-method, diagnostics, migration, and concurrency contract, see [`docs/AUTH_LIFECYCLE.md`](docs/AUTH_LIFECYCLE.md).
175
+
176
+ GraphQL uses ServiceNow's `/api/now/graphql` endpoint and sends `{query, variables}` through the authenticated SDK transport. The command preserves GraphQL `data` and `errors`; introspection and available namespaces depend on the target instance.
177
+
170
178
  ## Local data
171
179
 
172
180
  JSN stores durable local data under `~/.jsn/`. Documentation lives in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.13",
3
+ "version": "4.3.7-dev.15",
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/auth.js CHANGED
@@ -41,30 +41,32 @@ function credKey(instance, username) {
41
41
 
42
42
  // ─── PKCE state persistence (shared with Go version) ───
43
43
 
44
- function pkceStatePath(instance) {
44
+ function pkceStatePath(instance, username) {
45
45
  const dir = path.join(globalConfigDir(), 'pkce');
46
46
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
47
- const filename = sanitizeKeyPart(instance) + '.json';
47
+ // A known profile identity gets its own pending-login slot. Keep the bare
48
+ // instance slot for legacy/no-profile flows, where no identity exists yet.
49
+ const filename = credKey(instance, username) + '.json';
48
50
  return path.join(dir, filename);
49
51
  }
50
52
 
51
- function savePKCEState(instance, pkce) {
52
- const filePath = pkceStatePath(instance);
53
+ function savePKCEState(instance, pkce, username) {
54
+ const filePath = pkceStatePath(instance, username);
53
55
  fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
54
56
  }
55
57
 
56
- function loadPKCEState(instance) {
58
+ function loadPKCEState(instance, username) {
57
59
  try {
58
- const data = fs.readFileSync(pkceStatePath(instance), 'utf-8');
60
+ const data = fs.readFileSync(pkceStatePath(instance, username), 'utf-8');
59
61
  return JSON.parse(data);
60
62
  } catch {
61
63
  return null;
62
64
  }
63
65
  }
64
66
 
65
- function removePKCEState(instance) {
67
+ function removePKCEState(instance, username) {
66
68
  try {
67
- fs.unlinkSync(pkceStatePath(instance));
69
+ fs.unlinkSync(pkceStatePath(instance, username));
68
70
  } catch {
69
71
  // ignore
70
72
  }
@@ -746,7 +748,7 @@ export class AuthManager {
746
748
  instanceURL = normalizeInstanceURL(instanceURL);
747
749
  const clientID = getOAuthClientID();
748
750
  const pkce = generatePKCE();
749
- savePKCEState(instanceURL, pkce);
751
+ savePKCEState(instanceURL, pkce, username);
750
752
  const url = buildAuthURL(instanceURL, clientID, pkce);
751
753
  if (waitFile) {
752
754
  console.log(url);
@@ -766,7 +768,7 @@ export class AuthManager {
766
768
  const code = fs.readFileSync(filePath, 'utf-8').trim();
767
769
  if (code) {
768
770
  console.log(`\nAuthorization code found in ${filePath}`);
769
- removePKCEState(instanceURL);
771
+ removePKCEState(instanceURL, username);
770
772
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
771
773
  this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
772
774
  console.log('Token exchange successful!\n');
@@ -838,7 +840,7 @@ export class AuthManager {
838
840
  async loginWithCode(instanceURL, code, username = this._activeUsername()) {
839
841
  instanceURL = normalizeInstanceURL(instanceURL);
840
842
  const clientID = getOAuthClientID();
841
- const pkce = loadPKCEState(instanceURL);
843
+ const pkce = loadPKCEState(instanceURL, username);
842
844
  if (!pkce) {
843
845
  throw errAuth(
844
846
  `No pending login session for ${instanceURL}.\n\n` +
@@ -848,7 +850,7 @@ export class AuthManager {
848
850
  ` jsn auth login ${instanceURL} --code CODE`
849
851
  );
850
852
  }
851
- removePKCEState(instanceURL);
853
+ removePKCEState(instanceURL, username);
852
854
 
853
855
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
854
856
  this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
package/src/cli.js CHANGED
@@ -70,6 +70,7 @@ import { atfCmd } from './commands/atf.js';
70
70
  import { approvalsCmd } from './commands/approvals.js';
71
71
  import { perfCmd } from './commands/perf.js';
72
72
  import { diffCmd } from './commands/diff.js';
73
+ import { graphqlCmd } from './commands/graphql.js';
73
74
 
74
75
  function wrap(handler) {
75
76
  return async (argv) => {
@@ -373,6 +374,7 @@ export function buildCLI() {
373
374
  .command(approvalsCmd(wrap))
374
375
  .command(perfCmd(wrap))
375
376
  .command(diffCmd(wrap))
377
+ .command(graphqlCmd(wrap))
376
378
  .command(evalCmd(wrap))
377
379
  .command(restCmd(wrap))
378
380
  .command(skillCmd(wrap))
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import { declareCapabilities } from '../capabilities.js';
3
+
4
+ // GraphQL documents may contain mutations. Gate the whole command so normal
5
+ // instance/auth and read-only protections apply without attempting AST parsing.
6
+ declareCapabilities('graphql', { mutationSubcommands: [''] });
7
+
8
+ function readStdin() {
9
+ return new Promise((resolve, reject) => {
10
+ let data = '';
11
+ process.stdin.setEncoding('utf-8');
12
+ process.stdin.on('data', (chunk) => { data += chunk; });
13
+ process.stdin.on('end', () => resolve(data));
14
+ process.stdin.on('error', reject);
15
+ });
16
+ }
17
+
18
+ export async function resolveGraphQLInput(argv) {
19
+ const sources = [
20
+ argv.document !== undefined,
21
+ argv.query !== undefined,
22
+ argv.queryFile !== undefined,
23
+ argv.stdin === true,
24
+ ].filter(Boolean).length;
25
+ if (sources !== 1) {
26
+ throw new Error('Provide exactly one GraphQL document via positional document, --query, --query-file, or --stdin');
27
+ }
28
+
29
+ let query;
30
+ if (argv.document !== undefined) query = argv.document;
31
+ else if (argv.query !== undefined) query = argv.query;
32
+ else if (argv.queryFile !== undefined) query = fs.readFileSync(argv.queryFile, 'utf-8');
33
+ else query = await readStdin();
34
+
35
+ query = String(query || '').trim();
36
+ if (!query) throw new Error('GraphQL document must not be empty');
37
+ return query;
38
+ }
39
+
40
+ export function parseGraphQLVariables(value) {
41
+ if (value === undefined) return {};
42
+ let variables;
43
+ try {
44
+ variables = JSON.parse(value);
45
+ } catch {
46
+ throw new Error('--variables must be valid JSON');
47
+ }
48
+ if (!variables || typeof variables !== 'object' || Array.isArray(variables)) {
49
+ throw new Error('--variables must be a JSON object');
50
+ }
51
+ return variables;
52
+ }
53
+
54
+ export function graphqlCmd(wrap) {
55
+ return {
56
+ command: 'graphql [document]',
57
+ describe: 'Execute a GraphQL document against ServiceNow Now GraphQL',
58
+ builder: (yargs) => yargs
59
+ .positional('document', {
60
+ describe: 'GraphQL document string',
61
+ type: 'string',
62
+ })
63
+ .option('query', {
64
+ type: 'string',
65
+ describe: 'GraphQL document string (alternative to positional)',
66
+ })
67
+ .option('query-file', {
68
+ type: 'string',
69
+ describe: 'Read the GraphQL document from a file',
70
+ })
71
+ .option('variables', {
72
+ type: 'string',
73
+ describe: 'GraphQL variables as a JSON object',
74
+ })
75
+ .option('stdin', {
76
+ type: 'boolean',
77
+ describe: 'Read the GraphQL document from stdin',
78
+ }),
79
+ handler: wrap(async (argv, app) => {
80
+ const query = await resolveGraphQLInput(argv);
81
+ const variables = parseGraphQLVariables(argv.variables);
82
+ const endpoint = `${app.getEffectiveInstance()}/api/now/graphql`;
83
+ const result = await app.sdk.request(endpoint, {
84
+ method: 'POST',
85
+ body: JSON.stringify({ query, variables }),
86
+ });
87
+ const errorCount = Array.isArray(result?.errors) ? result.errors.length : 0;
88
+ app.ok(result, {
89
+ summary: errorCount
90
+ ? `GraphQL returned ${errorCount} error(s)`
91
+ : 'GraphQL request succeeded',
92
+ });
93
+ }),
94
+ };
95
+ }