@jacebenson/jsn 0.0.9 → 0.1.0

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.
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: servicenow
3
+ description: |
4
+ Interact with ServiceNow instances via the jsn CLI. Use when working with ServiceNow
5
+ development, administration, or data exploration. Handles tables, records, business rules,
6
+ flows, script includes, ACLs, update sets, and more. Triggered by ServiceNow URLs
7
+ (service-now.com, servicenow.com) or when the user mentions ServiceNow, jsn, servicenow,
8
+ or related terms like tables, records, business rules, flows, script includes, ACLs,
9
+ update sets, or encoded queries.
10
+ license: MIT
11
+ compatibility: |
12
+ Requires jsn CLI (github.com/jacebenson/jsn). Install via:
13
+ curl -fsSL https://jsn.jace.pro/install | bash
14
+ Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
+
16
+ Windows users: Download from GitHub releases. If jsn is not in your PATH, the setup
17
+ command will automatically detect its location and show the correct command path
18
+ (e.g., `.\jsn.exe tables list` instead of `jsn tables list`).
19
+ metadata:
20
+ author: jacebenson
21
+ version: "1.0.0"
22
+ repository: https://github.com/jacebenson/jsn
23
+ ---
24
+
25
+ # Jace's ServiceNow CLI
26
+
27
+ Explore and manage ServiceNow instances. Works standalone or with AI agents.
28
+
29
+ ## Discovery
30
+
31
+ Commands are self-documenting. Use these to learn what's available:
32
+
33
+ ```bash
34
+ jsn --help # Root help with all top-level commands
35
+ jsn <command> --help # Detailed usage, flags, and examples for any command
36
+ jsn dev # Shows categorized dev subcommands
37
+ ```
38
+
39
+ ## Agent Rules
40
+
41
+ 1. **Output modes** — `--json` when parsing data; `--md` when presenting to humans; `--agent` for automation
42
+ 2. **Use sys_id for updates** — All update/delete operations require sys_id
43
+ 3. **Check auth first** — Run `jsn auth status` before operations
44
+ 4. **NEVER logout** — Only run `jsn auth logout` if the user explicitly asks
45
+ 5. **Use `--profile <name>`** to target a specific instance, or `jsn profiles use <name>` to change default
46
+ 6. **Before using `eval` or `rest`** — Ask yourself: *"Have I checked if there's a more specific jsn command?"* Verify `jsn records --table <name> create` won't work first. Prefer specific over generic over escape hatch over eval.
47
+ 7. **CONFIRM before destructive operations** — Always show the user exactly what will be created, updated, or deleted and ask for explicit confirmation before executing. Never run `create`, `update`, `delete`, `set`, or `remove` commands without user approval.
48
+
49
+ ## ⚠️ Destructive Operations Require Confirmation
50
+
51
+ **jsn does not have a `--confirm` or `--dry-run` flag.** It assumes you mean what you type. This means **YOU** (the agent) are the safety layer.
52
+
53
+ **Before any write operation, you MUST:**
54
+
55
+ 1. **Show the user what you found** — Run a `list` or `show` command first
56
+ 2. **Present the exact command you plan to run** — Include the sys_id, table name, and data
57
+ 3. **Wait for explicit approval** — "Proceed? [y/N]" or similar
58
+
59
+ **Examples of destructive commands that require confirmation:**
60
+ - `jsn records --table <name> create`
61
+ - `jsn records --table <name> update <sys_id>`
62
+ - `jsn records --table <name> delete <sys_id>`
63
+ - `jsn dev updatesets set <name>`
64
+ - `jsn profiles remove <name>`
65
+ - Any `delete`, `update`, `create`, `set`, or `remove` subcommand
66
+
67
+ **Good pattern:**
68
+ ```
69
+ User: "Delete that incident"
70
+ Agent: "Found: INC0010001 'Server down' (sys_id: abc123...)
71
+ Command: jsn records --table incident delete abc123...
72
+ Proceed? [y/N]"
73
+ User: "y"
74
+ Agent: [runs command]
75
+ ```
76
+
77
+ **Bad pattern:**
78
+ ```
79
+ User: "Delete that incident"
80
+ Agent: [immediately runs jsn records --table incident delete ...]
81
+ ```
82
+
83
+ ## ⚠️ AVOID `jsn eval` for Record Creation
84
+
85
+ **Why:** `jsn eval` runs server-side JavaScript in the global scope. It returns HTTP 200 even when:
86
+ - The insert fails due to ACL violations
87
+ - Mandatory fields are missing
88
+ - The record lands in the wrong scope
89
+ - Logic errors prevent the insert entirely
90
+
91
+ **The AI trap:** Agents see "success" and assume the record was created. It wasn't.
92
+
93
+ **Always use instead:** `jsn records --table <name> create` — it returns the created record with sys_id or an explicit validation error.
94
+
95
+ **Only use `eval` when:** No specific command exists, `records` lacks required fields, and `rest` doesn't work. This is rare.
96
+
97
+ ## Command Hierarchy
98
+
99
+ Pick the most specific tool for the job. **Never default to eval** — it's the last resort:
100
+
101
+ 1. **Specific commands** — `jsn flows`, `jsn rules`, `jsn jobs`, etc. — curated views with domain-aware formatting and validation
102
+ 2. **`jsn records --table <name>`** — generic CRUD on any table (the workhorse, preferred for record creation)
103
+ 3. **`jsn rest`** — raw Table API escape hatch when records command lacks required fields
104
+ 4. **`jsn eval`** — ⚠️ **LAST RESORT ONLY** — server-side script execution when no other option exists
105
+ 5. **Ask the human** — if none of the above work. Never generate standalone GlideRecord scripts as a fallback.
106
+
107
+ ## JSON Envelope
108
+
109
+ All commands support `--json`. The envelope structure:
110
+
111
+ ```json
112
+ {
113
+ "ok": true,
114
+ "data": [ ... ],
115
+ "summary": "5 records",
116
+ "breadcrumbs": [
117
+ {"action": "show", "cmd": "jsn records --table incident <sys_id>", "description": "View details"}
118
+ ]
119
+ }
120
+ ```
121
+
122
+ Breadcrumbs suggest next commands — follow them for navigation.
123
+
124
+ ## Configuration
125
+
126
+ ```
127
+ ~/.config/servicenow/
128
+ ├── config.json # Profiles and settings
129
+ └── credentials.json # Auth tokens (fallback)
130
+
131
+ .servicenow/ # Per-repo config (optional)
132
+ └── config.json # Project-specific settings
133
+ ```
134
+
135
+ **Environment variables:**
136
+ - `SERVICENOW_TOKEN` — Override stored token
137
+ - `SERVICENOW_INSTANCE` — Override instance URL
package/src/app.js CHANGED
@@ -109,7 +109,7 @@ export class App {
109
109
 
110
110
  const scopeFormatted = `[${scope}]`;
111
111
 
112
- process.stderr.write('# Use `jsn updateset use` or `jsn scope use` to change scope/updateset\n');
112
+ process.stderr.write('# Use `jsn dev scopes` to change scope, `jsn dev updatesets set` to change updateset\n');
113
113
  process.stderr.write('PROFILE USER [SCOPE] UPDATE SET\n');
114
114
 
115
115
  const profileStr = `]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}]8;;\x07`;
package/src/auth.js CHANGED
@@ -1,19 +1,59 @@
1
1
  // OAuth 2.0 with PKCE authentication
2
+ // Credentials are stored in the OS keyring (shared with Go version via libsecret/secret-tool)
3
+ // Falls back to file-based storage when keyring is unavailable.
2
4
 
3
5
  import fs from 'node:fs';
4
6
  import path from 'node:path';
5
7
  import crypto from 'node:crypto';
6
8
  import readline from 'node:readline';
9
+ import { execSync } from 'node:child_process';
7
10
  import { globalConfigDir, normalizeInstanceURL } from './config.js';
8
11
  import { errAuth } from './errors.js';
9
12
 
13
+ // ─── PKCE state persistence (shared with Go version) ───
14
+
15
+ function pkceStatePath(instance) {
16
+ const dir = path.join(globalConfigDir(), 'pkce');
17
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
18
+ const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
19
+ return path.join(dir, filename);
20
+ }
21
+
22
+ function savePKCEState(instance, pkce) {
23
+ const filePath = pkceStatePath(instance);
24
+ fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
25
+ }
26
+
27
+ function loadPKCEState(instance) {
28
+ try {
29
+ const data = fs.readFileSync(pkceStatePath(instance), 'utf-8');
30
+ return JSON.parse(data);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function removePKCEState(instance) {
37
+ try {
38
+ fs.unlinkSync(pkceStatePath(instance));
39
+ } catch {
40
+ // ignore
41
+ }
42
+ }
43
+
10
44
  const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
11
45
  const REDIRECT_URI = '/sdk-oauth.do';
12
46
 
47
+ // Keychain constants — same as Go version (internal/auth/store.go)
48
+ const KEYRING_SERVICE = 'servicenow-cli';
49
+ const KEYRING_ATTR_SERVICE = 'service';
50
+ const KEYRING_ATTR_USERNAME = 'username';
51
+
13
52
  function credentialsPath(instance) {
14
53
  const dir = path.join(globalConfigDir(), 'credentials');
15
54
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
16
- const filename = Buffer.from(instance).toString('base64url') + '.json';
55
+ // Match Go's filename encoding: replace :// and / and : with _
56
+ const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
17
57
  return path.join(dir, filename);
18
58
  }
19
59
 
@@ -37,10 +77,66 @@ function buildAuthURL(instanceURL, clientID, pkce) {
37
77
  u.searchParams.set('code_challenge', pkce.code_challenge);
38
78
  u.searchParams.set('code_challenge_method', 'S256');
39
79
  u.searchParams.set('scope', 'openid');
80
+ u.searchParams.set('approval_prompt', 'force');
40
81
  return u.toString();
41
82
  }
42
83
 
84
+ // ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
85
+
86
+ function keyringLookup(instance) {
87
+ try {
88
+ const result = execSync(
89
+ `secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
90
+ { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
91
+ );
92
+ const trimmed = result.trim();
93
+ if (!trimmed) return null;
94
+ const parsed = JSON.parse(trimmed);
95
+ // Normalize field names from Go's format to Node.js format
96
+ return {
97
+ auth_method: parsed.auth_method || 'oauth',
98
+ access_token: parsed.access_token || parsed.AccessToken || '',
99
+ refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
100
+ expires_at: parsed.expires_at || parsed.ExpiresAt || 0,
101
+ created_at: parsed.created_at || parsed.CreatedAt || 0,
102
+ };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function keyringStore(instance, creds) {
109
+ try {
110
+ execSync(
111
+ `secret-tool store --label="Password for '${instance}' on '${KEYRING_SERVICE}'" ` +
112
+ `${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
113
+ { stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
114
+ );
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function keyringDelete(instance) {
122
+ try {
123
+ execSync(
124
+ `secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
125
+ { stdio: 'ignore' }
126
+ );
127
+ } catch {
128
+ // ignore
129
+ }
130
+ }
131
+
132
+ // ─── File-based storage (fallback) ───
133
+
43
134
  function loadCredentials(instance) {
135
+ // Try keyring first (shared with Go version)
136
+ const keyringCreds = keyringLookup(instance);
137
+ if (keyringCreds) return keyringCreds;
138
+
139
+ // Fall back to file-based storage
44
140
  try {
45
141
  const data = fs.readFileSync(credentialsPath(instance), 'utf-8');
46
142
  return JSON.parse(data);
@@ -50,10 +146,14 @@ function loadCredentials(instance) {
50
146
  }
51
147
 
52
148
  function saveCredentials(instance, creds) {
53
- fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
149
+ // Try keyring first, fall back to file
150
+ if (!keyringStore(instance, creds)) {
151
+ fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
152
+ }
54
153
  }
55
154
 
56
155
  function deleteCredentials(instance) {
156
+ keyringDelete(instance);
57
157
  try {
58
158
  fs.unlinkSync(credentialsPath(instance));
59
159
  } catch {
@@ -210,6 +310,43 @@ export class AuthManager {
210
310
  return newCreds;
211
311
  }
212
312
 
313
+ /**
314
+ * Build an OAuth authorization URL and persist PKCE state for later use.
315
+ * After calling this, the user can visit the URL in a browser and then
316
+ * call loginWithCode() with the resulting authorization code.
317
+ */
318
+ buildAuthURL(instanceURL) {
319
+ instanceURL = normalizeInstanceURL(instanceURL);
320
+ const clientID = getOAuthClientID();
321
+ const pkce = generatePKCE();
322
+ savePKCEState(instanceURL, pkce);
323
+ return buildAuthURL(instanceURL, clientID, pkce);
324
+ }
325
+
326
+ /**
327
+ * Complete login using an authorization code obtained from a prior buildAuthURL() call.
328
+ * The PKCE state must have been saved by an earlier buildAuthURL() call.
329
+ */
330
+ async loginWithCode(instanceURL, code) {
331
+ instanceURL = normalizeInstanceURL(instanceURL);
332
+ const clientID = getOAuthClientID();
333
+ const pkce = loadPKCEState(instanceURL);
334
+ if (!pkce) {
335
+ throw errAuth(
336
+ `No pending login session for ${instanceURL}.\n\n` +
337
+ 'Run without --code first to generate one:\n' +
338
+ ` jsn auth login ${instanceURL} --print-url\n\n` +
339
+ 'This generates the PKCE challenge and saves it. Then call:\n' +
340
+ ` jsn auth login ${instanceURL} --code CODE`
341
+ );
342
+ }
343
+ removePKCEState(instanceURL);
344
+
345
+ const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
346
+ saveCredentials(instanceURL, newCreds);
347
+ return newCreds;
348
+ }
349
+
213
350
  async exchangeCode(instanceURL, clientID, code, pkce) {
214
351
  const tokenURL = `${instanceURL.replace(/\/$/, '')}/oauth_token.do`;
215
352
  const body = new URLSearchParams();
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import { hideBin } from 'yargs/helpers';
5
5
  import process from 'node:process';
6
6
  import { loadConfig, getEffectiveInstance } from './config.js';
7
7
  import { App } from './app.js';
8
+ import { renderHelp } from './help.js';
8
9
 
9
10
  // Command modules
10
11
  import { setupCmd } from './commands/setup.js';
@@ -22,6 +23,7 @@ import { groupRolesCmd } from './commands/grouproles.js';
22
23
  import { ticketsCmd } from './commands/tickets.js';
23
24
  import { versionCmd } from './commands/version.js';
24
25
  import { devCmd } from './commands/dev.js';
26
+ import { skillCmd } from './commands/skill.js';
25
27
 
26
28
  function wrap(handler) {
27
29
  return async (argv) => {
@@ -84,7 +86,7 @@ export const cli = yargs(hideBin(process.argv))
84
86
  type: 'boolean',
85
87
  global: true,
86
88
  })
87
- .middleware((argv) => {
89
+ .middleware(async (argv) => {
88
90
  // Determine format from flags
89
91
  let format = 'auto';
90
92
  if (argv.json) format = 'json';
@@ -103,7 +105,7 @@ export const cli = yargs(hideBin(process.argv))
103
105
 
104
106
  // Check auth for non-auth commands
105
107
  const cmd = argv._[0];
106
- const skipAuth = ['help', 'version', 'setup', 'auth', undefined].includes(cmd);
108
+ const skipAuth = ['help', 'version', 'setup', 'auth', 'profiles', 'profile', 'skill', undefined].includes(cmd);
107
109
  if (!skipAuth) {
108
110
  const instance = getEffectiveInstance(cfg);
109
111
  if (!argv.app.auth.isAuthenticated() && instance) {
@@ -114,9 +116,9 @@ export const cli = yargs(hideBin(process.argv))
114
116
  }
115
117
  }
116
118
 
117
- // Print context header for interactive terminals
118
- if (!['help', 'version', 'completion'].includes(cmd)) {
119
- argv.app.printContextHeader();
119
+ // Print context header for interactive terminals (at the TOP, before command output)
120
+ if (!['help', 'version', 'completion', 'skill'].includes(cmd)) {
121
+ await argv.app.printContextHeader();
120
122
  }
121
123
  })
122
124
  .command(setupCmd(wrap))
@@ -133,10 +135,20 @@ export const cli = yargs(hideBin(process.argv))
133
135
  .command(groupRolesCmd(wrap))
134
136
  .command(ticketsCmd(wrap))
135
137
  .command(devCmd(wrap))
138
+ .command(skillCmd(wrap))
136
139
  .command(versionCmd(wrap))
137
140
  .demandCommand(1, 'You must specify a command')
138
141
  .help('help', 'Show help')
139
142
  .version(false)
140
143
  .strictCommands()
141
144
  .strictOptions(false)
142
- .epilogue('LEARN MORE\n Use "jsn <command> --help" for more information about a command.');
145
+ .fail((msg, err) => {
146
+ if (err) throw err;
147
+ // No command given → show custom grouped help instead of yargs error
148
+ if (msg === 'You must specify a command') {
149
+ process.stdout.write(renderHelp());
150
+ process.exit(0);
151
+ }
152
+ process.stderr.write(`Error: ${msg}\n`);
153
+ process.exit(1);
154
+ });
@@ -2,14 +2,14 @@
2
2
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
3
 
4
4
  import { getStringField, formatRecordForDisplay, buildQuerySuffix } from '../helpers.js';
5
- import { select } from '@inquirer/prompts';
5
+ import search from '@inquirer/search';
6
6
  import { isTTY, FormatAuto } from '../output.js';
7
7
 
8
8
  export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
9
9
  return {
10
10
  command: `${displayName} [subcommand]`,
11
11
  aliases: [table, alias],
12
- describe: `Manage ${displayName}`,
12
+ describe: `Manage ${displayName} (e.g. "${displayName} list --query priority=1")`,
13
13
  builder: (yargs) => {
14
14
  return yargs
15
15
  .command({
@@ -70,9 +70,13 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
70
70
 
71
71
  let selectedNumber;
72
72
  try {
73
- selectedNumber = await select({
73
+ selectedNumber = await search({
74
74
  message: `Select a ${displayName.slice(0, -1)}:`,
75
- choices,
75
+ source: async (input) => {
76
+ if (!input) return choices;
77
+ const term = input.toLowerCase();
78
+ return choices.filter(c => c.name.toLowerCase().includes(term));
79
+ },
76
80
  });
77
81
  } catch (err) {
78
82
  if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
@@ -1,61 +1,211 @@
1
1
  import { getEffectiveInstance, normalizeInstanceURL } from '../config.js';
2
+ import { errUsage } from '../errors.js';
3
+ import process from 'node:process';
2
4
 
3
5
  export function authCmd(wrap) {
4
6
  return {
5
7
  command: 'auth <subcommand>',
6
- describe: 'Manage authentication',
8
+ describe: 'Manage OAuth authentication',
7
9
  builder: (yargs) => {
8
10
  return yargs
9
11
  .command({
10
12
  command: 'login [instance]',
11
- describe: 'Authenticate with a ServiceNow instance',
13
+ describe: 'Authenticate with a ServiceNow instance via OAuth',
14
+ builder: (sub) => sub
15
+ .option('code', {
16
+ describe: 'Authorization code from browser (bypasses interactive prompt)',
17
+ type: 'string',
18
+ })
19
+ .option('print-url', {
20
+ describe: 'Print the OAuth URL and exit (saves PKCE state for --code)',
21
+ type: 'boolean',
22
+ }),
12
23
  handler: wrap(async (argv, app) => {
13
- const instance = argv.instance ? normalizeInstanceURL(argv.instance) : getEffectiveInstance(app.config);
14
- if (!instance) {
15
- throw new Error('No instance configured. Set via --instance or run: jsn setup');
24
+ let instanceURL;
25
+ if (argv.instance) {
26
+ // Check if it's a profile name first
27
+ const profiles = app.config.profiles || {};
28
+ if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
29
+ instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
30
+ } else {
31
+ instanceURL = normalizeInstanceURL(argv.instance);
32
+ }
33
+ } else {
34
+ instanceURL = getEffectiveInstance(app.config);
35
+ if (!instanceURL) {
36
+ // Try interactive profile picker
37
+ const profileNames = Object.keys(app.config.profiles || {});
38
+ if (profileNames.length > 0 && app.isInteractive()) {
39
+ const { search } = await import('@inquirer/prompts');
40
+ const choices = profileNames.map(name => ({
41
+ name: `${name} — ${app.config.profiles[name].instance_url}`,
42
+ value: name,
43
+ }));
44
+ const selected = await search({
45
+ message: 'Select a profile:',
46
+ source: (input) => {
47
+ const filter = (input || '').toLowerCase();
48
+ return choices.filter(c => c.name.toLowerCase().includes(filter));
49
+ },
50
+ });
51
+ instanceURL = normalizeInstanceURL(app.config.profiles[selected].instance_url);
52
+ } else {
53
+ throw errUsage(`Instance URL required.
54
+
55
+ Examples:
56
+ jsn auth login https://dev12345.service-now.com
57
+ jsn auth login dev373698
58
+ jsn auth login https://acme.service-now.com
59
+
60
+ Find your instance URL in your browser's address bar when logged into ServiceNow.`);
61
+ }
62
+ }
63
+ }
64
+
65
+ // --print-url: just print the URL and exit
66
+ if (argv.printUrl) {
67
+ const authURL = app.auth.buildAuthURL(instanceURL);
68
+ console.log(authURL);
69
+ return;
70
+ }
71
+
72
+ // --code: exchange the provided authorization code directly
73
+ if (argv.code) {
74
+ await app.auth.loginWithCode(instanceURL, argv.code);
75
+ } else {
76
+ // Interactive: full OAuth flow with browser + prompt
77
+ await app.auth.login(instanceURL);
16
78
  }
17
- await app.auth.login(instance);
18
- app.ok({ authenticated: true, instance }, { summary: `Authenticated with ${instance}` });
79
+
80
+ // Verify auth by fetching current user
81
+ let username = '';
82
+ try {
83
+ if (!app.sdk) {
84
+ const { SDKClient } = await import('../sdk.js');
85
+ app.sdk = new SDKClient(instanceURL, app.auth);
86
+ }
87
+ const user = await app.sdk.getCurrentUser();
88
+ username = user?.user_name || user?.name || '';
89
+ } catch {
90
+ // Non-fatal — token is saved, just couldn't verify
91
+ }
92
+
93
+ // Save to profiles
94
+ const profileName = instanceURL.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
95
+ if (!app.config.profiles) {
96
+ app.config.profiles = {};
97
+ }
98
+ app.config.profiles[profileName] = {
99
+ instance_url: instanceURL,
100
+ auth_method: 'oauth',
101
+ username: username || undefined,
102
+ };
103
+
104
+ // Set as default if this is the first one
105
+ const setDefault = !app.config.instanceURL && !app.config.defaultProfile;
106
+ if (setDefault) {
107
+ app.config.instanceURL = instanceURL;
108
+ app.config.defaultProfile = profileName;
109
+ }
110
+
111
+ const { saveConfig } = await import('../config.js');
112
+ saveConfig(app.config);
113
+
114
+ const result = {
115
+ authenticated: true,
116
+ instance: instanceURL,
117
+ username: username || undefined,
118
+ default: setDefault || undefined,
119
+ };
120
+ const summary = username
121
+ ? `✓ Authenticated to ${instanceURL} as ${username}`
122
+ : `✓ Authenticated to ${instanceURL}`;
123
+ app.ok(result, { summary });
19
124
  }),
20
125
  })
21
126
  .command({
22
- command: 'logout',
23
- describe: 'Remove stored credentials',
24
- handler: wrap(async (_argv, app) => {
25
- const instance = getEffectiveInstance(app.config);
26
- if (!instance) {
27
- throw new Error('No instance configured');
127
+ command: 'logout [instance]',
128
+ describe: 'Remove stored OAuth credentials',
129
+ handler: wrap(async (argv, app) => {
130
+ let instanceURL;
131
+ if (argv.instance) {
132
+ instanceURL = normalizeInstanceURL(argv.instance);
133
+ } else {
134
+ instanceURL = getEffectiveInstance(app.config);
135
+ if (!instanceURL) {
136
+ throw errUsage(`No instance specified.
137
+
138
+ Examples:
139
+ jsn auth logout
140
+ jsn auth logout https://dev12345.service-now.com`);
141
+ }
28
142
  }
29
- app.auth.logout(instance);
30
- app.ok({ logged_out: true, instance }, { summary: `Logged out from ${instance}` });
143
+ app.auth.logout(instanceURL);
144
+ app.ok({ logged_out: true, instance: instanceURL }, { summary: `✓ Logged out from ${instanceURL}` });
31
145
  }),
32
146
  })
33
147
  .command({
34
148
  command: 'status',
35
- describe: 'Show authentication status',
149
+ describe: 'Show detailed authentication status',
36
150
  handler: wrap(async (_argv, app) => {
37
- const instance = getEffectiveInstance(app.config);
38
- if (!instance) {
39
- app.ok({ authenticated: false, instance: null }, { summary: 'No instance configured' });
40
- return;
151
+ const defaultInstance = getEffectiveInstance(app.config);
152
+
153
+ // Check environment auth
154
+ const envToken = process.env.SERVICENOW_OAUTH_TOKEN || '';
155
+
156
+ const profiles = [];
157
+ for (const [name, profile] of Object.entries(app.config.profiles || {})) {
158
+ const isAuth = app.auth.isAuthenticatedFor(profile.instance_url);
159
+ profiles.push({
160
+ name,
161
+ instance: profile.instance_url,
162
+ authenticated: isAuth,
163
+ default: profile.instance_url === defaultInstance,
164
+ });
41
165
  }
42
- const isAuth = app.auth.isAuthenticatedFor(instance);
43
- app.ok({ authenticated: isAuth, instance }, { summary: isAuth ? `Authenticated with ${instance}` : `Not authenticated with ${instance}` });
166
+
167
+ app.ok({
168
+ default_instance: defaultInstance,
169
+ authenticated: app.auth.isAuthenticated(),
170
+ environment_auth: envToken ? true : undefined,
171
+ profiles,
172
+ }, { summary: `${profiles.length} profile(s)` });
44
173
  }),
45
174
  })
46
175
  .command({
47
- command: 'refresh',
48
- describe: 'Refresh OAuth token',
49
- handler: wrap(async (_argv, app) => {
50
- const instance = getEffectiveInstance(app.config);
51
- if (!instance) {
52
- throw new Error('No instance configured');
176
+ command: 'refresh [instance]',
177
+ describe: 'Refresh OAuth token for an instance',
178
+ handler: wrap(async (argv, app) => {
179
+ let instanceURL;
180
+ if (argv.instance) {
181
+ // Check if it's a profile name or URL
182
+ const profiles = app.config.profiles || {};
183
+ if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
184
+ instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
185
+ } else {
186
+ instanceURL = normalizeInstanceURL(argv.instance);
187
+ }
188
+ } else {
189
+ instanceURL = getEffectiveInstance(app.config);
190
+ if (!instanceURL) {
191
+ throw errUsage(`No instance specified and no default configured.
192
+
193
+ Examples:
194
+ jsn auth refresh
195
+ jsn auth refresh https://dev12345.service-now.com
196
+ jsn auth refresh dev12345`);
197
+ }
53
198
  }
54
- await app.auth.refreshToken(instance, await app.auth.getCredentialsFor(instance));
55
- app.ok({ refreshed: true, instance }, { summary: `Token refreshed for ${instance}` });
56
- }),
57
- })
58
199
 
200
+ const creds = await app.auth.getCredentialsFor(instanceURL);
201
+ const refreshed = await app.auth.refreshToken(instanceURL, creds);
202
+ app.ok({
203
+ refreshed: true,
204
+ instance: instanceURL,
205
+ expires_at: refreshed.expires_at,
206
+ }, { summary: `✓ Token refreshed for ${instanceURL}` });
207
+ }),
208
+ });
59
209
  },
60
210
  handler: () => {},
61
211
  };