@jacebenson/jsn 2.0.2 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
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
@@ -180,12 +180,33 @@ function loadCredentials(instance) {
180
180
  }
181
181
 
182
182
  function saveCredentials(instance, creds) {
183
+ // Stamp last_seen on every credential save
184
+ creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
183
185
  // Try keyring first, fall back to file
184
186
  if (!keyringStore(instance, creds)) {
185
187
  fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
186
188
  }
187
189
  }
188
190
 
191
+ /**
192
+ * Get the last_seen timestamp for an instance, if available.
193
+ */
194
+ function getLastSeen(instance) {
195
+ const creds = loadCredentials(instance);
196
+ if (!creds || !creds.last_seen) return null;
197
+ return creds.last_seen;
198
+ }
199
+
200
+ /**
201
+ * Update the last_seen timestamp for an instance to now.
202
+ */
203
+ function touchLastSeen(instance) {
204
+ const creds = loadCredentials(instance);
205
+ if (!creds) return;
206
+ creds.last_seen = Math.floor(Date.now() / 1000);
207
+ saveCredentials(instance, creds);
208
+ }
209
+
189
210
  function deleteCredentials(instance) {
190
211
  keyringDelete(instance);
191
212
  try {
@@ -250,6 +271,14 @@ export class AuthManager {
250
271
  this.httpClient = { timeout: 30000 };
251
272
  }
252
273
 
274
+ getLastSeen(instance) {
275
+ return getLastSeen(instance);
276
+ }
277
+
278
+ touchLastSeen(instance) {
279
+ return touchLastSeen(instance);
280
+ }
281
+
253
282
  isAuthenticated() {
254
283
  if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
255
284
  if (getBasicAuthFromEnv()) return true;
@@ -515,3 +544,5 @@ export class AuthManager {
515
544
  deleteCredentials(instance);
516
545
  }
517
546
  }
547
+
548
+ export { saveCredentials, loadCredentials, askHidden };
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import process from 'node:process';
6
6
  import { loadConfig, getEffectiveInstance } from './config.js';
7
7
  import { App } from './app.js';
8
8
  import { renderHelp } from './help.js';
9
+ import { isMutationCommand } from './mutations.js';
9
10
 
10
11
  // Command modules
11
12
  import { setupCmd } from './commands/setup.js';
@@ -122,6 +123,24 @@ export const cli = yargs(hideBin(process.argv))
122
123
  }
123
124
  }
124
125
 
126
+ // Read-only profile check — block mutation commands on read-only profiles
127
+ const activeProfileName = cfg.activeProfile || cfg.defaultProfile;
128
+ if (activeProfileName && cfg.profiles[activeProfileName] && cfg.profiles[activeProfileName].read_only) {
129
+ const skipReadOnlyCheck = ['help', 'version', 'setup', 'auth', 'profiles', 'profile', 'skill', undefined].includes(cmd);
130
+ if (!skipReadOnlyCheck && isMutationCommand(argv)) {
131
+ process.stderr.write(`\nšŸ”’ Profile "${activeProfileName}" is read-only.\n`);
132
+ process.stderr.write(` Mutation commands are disabled on this profile.\n`);
133
+ const nonReadOnlyProfiles = Object.entries(cfg.profiles || {})
134
+ .filter(([n, p]) => n !== activeProfileName && !p.read_only)
135
+ .map(([n]) => n);
136
+ if (nonReadOnlyProfiles.length > 0) {
137
+ process.stderr.write(` Switch to a writable profile: jsn profiles use ${nonReadOnlyProfiles[0]}\n`);
138
+ }
139
+ process.stderr.write('\n');
140
+ process.exit(1);
141
+ }
142
+ }
143
+
125
144
  // Auto-check skill on every command (fire-and-forget, non-blocking)
126
145
  const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
127
146
  || process.env.JSN_NO_SKILL_CHECK === '1'
@@ -105,14 +105,28 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
105
105
  // Non-fatal — token is saved, just couldn't verify
106
106
  }
107
107
 
108
- // Save to profiles
109
- const profileName = instanceURL.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
108
+ // Save to profiles — deduplicate by instance URL
110
109
  if (!app.config.profiles) {
111
110
  app.config.profiles = {};
112
111
  }
112
+
113
+ // Check if a profile already exists for this instance
114
+ let profileName = null;
115
+ for (const [existingName, existingProfile] of Object.entries(app.config.profiles)) {
116
+ if (existingProfile.instance_url === instanceURL) {
117
+ profileName = existingName;
118
+ break;
119
+ }
120
+ }
121
+
122
+ // If no existing profile found, generate a name from the URL
123
+ if (!profileName) {
124
+ profileName = instanceURL.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
125
+ }
126
+
113
127
  app.config.profiles[profileName] = {
114
128
  instance_url: instanceURL,
115
- auth_method: 'oauth',
129
+ auth_method: argv.password ? 'basic' : 'oauth',
116
130
  username: username || undefined,
117
131
  };
118
132
 
@@ -130,11 +144,12 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
130
144
  authenticated: true,
131
145
  instance: instanceURL,
132
146
  username: username || undefined,
147
+ profile: profileName,
133
148
  default: setDefault || undefined,
134
149
  };
135
150
  const summary = username
136
- ? `āœ“ Authenticated to ${instanceURL} as ${username}`
137
- : `āœ“ Authenticated to ${instanceURL}`;
151
+ ? `āœ“ Authenticated to ${instanceURL} as ${username} (profile: ${profileName})`
152
+ : `āœ“ Authenticated to ${instanceURL} (profile: ${profileName})`;
138
153
  app.ok(result, { summary });
139
154
  }),
140
155
  })
@@ -170,21 +185,54 @@ Examples:
170
185
 
171
186
  const profiles = [];
172
187
  for (const [name, profile] of Object.entries(app.config.profiles || {})) {
173
- const isAuth = app.auth.isAuthenticatedFor(profile.instance_url);
188
+ const instance = profile.instance_url;
189
+ const isAuth = app.auth.isAuthenticatedFor(instance);
190
+ const lastSeen = app.auth.getLastSeen(instance);
191
+
192
+ // Try live verification
193
+ let verified = null;
194
+ let verifiedAt = null;
195
+ if (isAuth && instance) {
196
+ try {
197
+ const { SDKClient } = await import('../sdk.js');
198
+ const sdk = new SDKClient(instance, app.auth);
199
+ const user = await sdk.getCurrentUser();
200
+ if (user && user.user_name) {
201
+ verified = true;
202
+ verifiedAt = user.user_name;
203
+ }
204
+ } catch {
205
+ verified = false;
206
+ }
207
+ }
208
+
209
+ // Calculate days since last seen
210
+ let daysSinceLastSeen = null;
211
+ if (lastSeen) {
212
+ daysSinceLastSeen = Math.floor((Date.now() / 1000 - lastSeen) / 86400);
213
+ }
214
+
174
215
  profiles.push({
175
216
  name,
176
- instance: profile.instance_url,
217
+ instance,
177
218
  authenticated: isAuth,
178
- default: profile.instance_url === defaultInstance,
219
+ verified,
220
+ verified_as: verifiedAt || undefined,
221
+ last_seen: lastSeen || undefined,
222
+ days_since_last_seen: daysSinceLastSeen,
223
+ stale: daysSinceLastSeen > 7,
224
+ default: instance === defaultInstance,
179
225
  });
180
226
  }
181
227
 
182
- app.ok({
228
+ const result = {
183
229
  default_instance: defaultInstance,
184
230
  authenticated: app.auth.isAuthenticated(),
185
231
  environment_auth: envToken ? true : undefined,
186
232
  profiles,
187
- }, { summary: `${profiles.length} profile(s)` });
233
+ };
234
+
235
+ app.ok(result, { summary: `${profiles.length} profile(s)` });
188
236
  }),
189
237
  })
190
238
  .command({
@@ -1,4 +1,4 @@
1
- import { saveConfig } from '../config.js';
1
+ import { normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
2
2
 
3
3
  export function profilesCmd(wrap) {
4
4
  return {
@@ -7,6 +7,74 @@ export function profilesCmd(wrap) {
7
7
  describe: 'Manage configuration profiles',
8
8
  builder: (yargs) => {
9
9
  return yargs
10
+ .command({
11
+ command: 'create <name>',
12
+ describe: 'Create a new profile',
13
+ builder: (sub) => sub
14
+ .positional('name', {
15
+ describe: 'Profile name',
16
+ type: 'string',
17
+ })
18
+ .option('instance', {
19
+ alias: 'i',
20
+ describe: 'ServiceNow instance URL (e.g. dev12345.service-now.com)',
21
+ type: 'string',
22
+ })
23
+ .option('username', {
24
+ alias: 'u',
25
+ describe: 'Username for basic auth',
26
+ type: 'string',
27
+ })
28
+ .option('password', {
29
+ alias: 'p',
30
+ describe: 'Password for basic auth',
31
+ type: 'string',
32
+ })
33
+ .option('read-only', {
34
+ describe: 'Mark profile as read-only (blocks mutation commands)',
35
+ type: 'boolean',
36
+ default: false,
37
+ }),
38
+ handler: wrap(async (argv, app) => {
39
+ const name = argv.name;
40
+ if (app.config.profiles && app.config.profiles[name]) {
41
+ throw new Error(`Profile "${name}" already exists. Use "jsn profiles update" or remove it first.`);
42
+ }
43
+
44
+ let instance = argv.instance;
45
+ if (!instance) {
46
+ throw new Error(`Instance URL is required.\n\n jsn profiles create ${name} --instance https://dev12345.service-now.com`);
47
+ }
48
+ instance = normalizeInstanceURL(instance);
49
+
50
+ const profile = { instance_url: instance };
51
+
52
+ // If username/password provided, save credentials too
53
+ if (argv.username && argv.password) {
54
+ const { saveCredentials } = await import('../auth.js');
55
+ saveCredentials(instance, {
56
+ auth_method: 'basic',
57
+ username: argv.username,
58
+ password: argv.password,
59
+ });
60
+ profile.auth_method = 'basic';
61
+ profile.username = argv.username;
62
+ }
63
+
64
+ // If read-only flag is set, mark the profile
65
+ if (argv['read-only']) {
66
+ profile.read_only = true;
67
+ }
68
+
69
+ await setProfile(app.config, name, profile);
70
+
71
+ app.ok({
72
+ profile: name,
73
+ instance,
74
+ auth_method: profile.auth_method || 'oauth (not yet logged in)',
75
+ }, { summary: `Profile "${name}" created for ${instance}` });
76
+ }),
77
+ })
10
78
  .command({
11
79
  command: 'list',
12
80
  describe: 'List all profiles',
@@ -22,9 +90,26 @@ export function profilesCmd(wrap) {
22
90
  username: profile.username || '',
23
91
  authenticated: isAuth,
24
92
  default: isDefault,
93
+ read_only: profile.read_only || false,
25
94
  });
26
95
  }
27
- app.ok({ profiles }, { summary: `${profiles.length} profile(s)` });
96
+
97
+ // Build context-aware breadcrumb: suggest switching to a non-active profile
98
+ const breadcrumbs = [];
99
+ if (profiles.length > 0) {
100
+ const activeName = app.config.activeProfile || app.config.defaultProfile;
101
+ const nonActiveProfiles = profiles.filter(p => p.name !== activeName);
102
+ if (nonActiveProfiles.length > 0) {
103
+ const suggestName = nonActiveProfiles[0].name;
104
+ breadcrumbs.push({
105
+ action: 'switch',
106
+ cmd: `jsn profiles use ${suggestName}`,
107
+ description: 'Switch to a different profile',
108
+ });
109
+ }
110
+ }
111
+
112
+ app.ok({ profiles }, { summary: `${profiles.length} profile(s)`, breadcrumbs });
28
113
  }),
29
114
  })
30
115
  .command({
@@ -50,7 +135,8 @@ export function profilesCmd(wrap) {
50
135
  throw new Error('No profiles configured');
51
136
  }
52
137
  const profile = app.config.profiles[name];
53
- app.ok({ name, ...profile }, { summary: `Profile: ${name}` });
138
+ const readOnlyNote = profile.read_only ? ' [READ-ONLY]' : '';
139
+ app.ok({ name, ...profile }, { summary: `Profile: ${name}${readOnlyNote}` });
54
140
  }),
55
141
  })
56
142
  .command({
@@ -78,8 +164,9 @@ export function profilesCmd(wrap) {
78
164
  // When no subcommand is given, show help
79
165
  if (!argv._[1]) {
80
166
  console.log('Manage your ServiceNow instance profiles.\n');
81
- console.log('Usage: jsn profiles <list|show|use|remove> [options]\n');
167
+ console.log('Usage: jsn profiles <create|list|show|use|remove> [options]\n');
82
168
  console.log('Commands:');
169
+ console.log(' create <name> Create a new profile (--instance required)');
83
170
  console.log(' list List all profiles');
84
171
  console.log(' show [name] Show profile details');
85
172
  console.log(' use <name> Switch to a different profile');
@@ -1,10 +1,19 @@
1
1
  import readline from 'node:readline';
2
2
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
3
+ import { saveCredentials, loadCredentials, askHidden } from '../auth.js';
3
4
 
4
5
  export function setupCmd(wrap) {
5
6
  return {
6
7
  command: 'setup',
7
8
  describe: 'Interactive first-time setup',
9
+ builder: (yargs) => {
10
+ return yargs
11
+ .option('read-only', {
12
+ describe: 'Mark profile as read-only (blocks mutation commands)',
13
+ type: 'boolean',
14
+ default: false,
15
+ });
16
+ },
8
17
  handler: wrap(async (_argv, app) => {
9
18
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
19
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
@@ -20,16 +29,43 @@ export function setupCmd(wrap) {
20
29
  console.log(`Instance: ${instance}`);
21
30
 
22
31
  const profileName = await ask('Profile name (default): ') || 'default';
23
- await setProfile(app.config, profileName, { instance_url: instance });
32
+ const profile = { instance_url: instance };
33
+ if (_argv['read-only']) {
34
+ profile.read_only = true;
35
+ }
36
+
37
+ const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
38
+ const useBasic = authMethod.toLowerCase().startsWith('b');
39
+
40
+ if (useBasic) {
41
+ const existing = loadCredentials(instance);
42
+ if (existing && existing.auth_method === 'basic') {
43
+ console.log(`Using existing credentials for ${instance}`);
44
+ } else {
45
+ const username = await ask('Username: ');
46
+ rl.close(); // Close outer readline before askHidden creates its own
47
+ const password = await askHidden('Password: ');
48
+ saveCredentials(instance, { auth_method: 'basic', username, password });
49
+ profile.username = username;
50
+ console.log('Basic auth credentials saved');
51
+ }
52
+ profile.auth_method = 'basic';
53
+ } else {
54
+ profile.auth_method = 'oauth';
55
+ }
56
+
57
+ await setProfile(app.config, profileName, profile);
24
58
  // Set as active profile so subsequent commands know which instance to use
25
59
  app.config.activeProfile = profileName;
26
60
  app.config.defaultProfile = profileName;
27
61
  saveConfig(app.config);
28
62
 
29
- const loginNow = await ask('Login now? [Y/n]: ');
30
- if (!loginNow || loginNow.toLowerCase() !== 'n') {
31
- await app.auth.login(instance);
32
- console.log('Login successful!');
63
+ if (!useBasic) {
64
+ const loginNow = await ask('Login now? [Y/n]: ');
65
+ if (!loginNow || loginNow.toLowerCase() !== 'n') {
66
+ await app.auth.login(instance);
67
+ console.log('Login successful!');
68
+ }
33
69
  }
34
70
 
35
71
  rl.close();
@@ -0,0 +1,52 @@
1
+ // Centralized mutation command registry
2
+ // Used by the read-only profile middleware to block mutation commands.
3
+ // Each entry is a command path array matching argv._ tokens.
4
+
5
+ export const MUTATION_COMMANDS = [
6
+ // Incidents
7
+ ['incidents', 'create'],
8
+ ['incidents', 'update'],
9
+ ['incidents', 'delete'],
10
+ // Changes
11
+ ['changes', 'create'],
12
+ ['changes', 'update'],
13
+ ['changes', 'delete'],
14
+ // Records
15
+ ['records', 'create'],
16
+ ['records', 'update'],
17
+ ['records', 'delete'],
18
+ // Requests
19
+ ['requests', 'create'],
20
+ ['requests', 'update'],
21
+ ['requests', 'delete'],
22
+ // Tasks
23
+ ['tasks', 'create'],
24
+ ['tasks', 'update'],
25
+ ['tasks', 'delete'],
26
+ // Dev
27
+ ['dev', 'eval'],
28
+ ['dev', 'updatesets', 'set'],
29
+ ['dev', 'updatesets', 'create'],
30
+ ['dev', 'scopes', 'set'],
31
+ ];
32
+
33
+ /**
34
+ * Check if the parsed argv matches any mutation command pattern.
35
+ * @param {object} argv — yargs parsed argv with `_` array
36
+ * @returns {boolean}
37
+ */
38
+ export function isMutationCommand(argv) {
39
+ const cmd = argv._ || [];
40
+ for (const pattern of MUTATION_COMMANDS) {
41
+ if (pattern.length !== cmd.length) continue;
42
+ let match = true;
43
+ for (let i = 0; i < pattern.length; i++) {
44
+ if (pattern[i] !== cmd[i]) {
45
+ match = false;
46
+ break;
47
+ }
48
+ }
49
+ if (match) return true;
50
+ }
51
+ return false;
52
+ }
package/src/output.js CHANGED
@@ -176,11 +176,31 @@ export class OutputWriter {
176
176
  this.writer.write('\n');
177
177
  for (const p of data.profiles) {
178
178
  const prefix = p.default ? '* ' : ' ';
179
- const status = p.authenticated ? 'āœ“' : 'āœ—';
180
- if (p.username) {
181
- this.writer.write(`${prefix}${status} ${p.instance} (as ${p.username})\n`);
179
+ const authIcon = p.authenticated ? 'āœ“' : 'āœ—';
180
+
181
+ // Show verified status if we got one
182
+ let verifiedStr = '';
183
+ if (p.verified === true) {
184
+ verifiedStr = ' āœ…';
185
+ } else if (p.verified === false) {
186
+ verifiedStr = ' āš ļø';
187
+ }
188
+
189
+ // Show stale hint if >7 days since last seen
190
+ let staleStr = '';
191
+ if (p.stale && p.days_since_last_seen) {
192
+ staleStr = ` (${p.days_since_last_seen}d ago — may have been released)`;
193
+ }
194
+
195
+ // Show lock icon for read-only profiles
196
+ const lockIcon = p.read_only ? ' šŸ”’' : '';
197
+
198
+ if (p.name) {
199
+ this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
200
+ } else if (p.username) {
201
+ this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${verifiedStr}${staleStr}\n`);
182
202
  } else {
183
- this.writer.write(`${prefix}${status} ${p.instance}\n`);
203
+ this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
184
204
  }
185
205
  }
186
206
  }
package/src/sdk.js CHANGED
@@ -65,6 +65,13 @@ export class SDKClient {
65
65
  return null;
66
66
  }
67
67
 
68
+ // Stamp last_seen on successful API call
69
+ try {
70
+ if (this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
71
+ this.authProvider.touchLastSeen(this.baseURL);
72
+ }
73
+ } catch { /* non-fatal */ }
74
+
68
75
  return JSON.parse(body);
69
76
  } catch (err) {
70
77
  if (err.name === 'AbortError') {