@jacebenson/jsn 2.0.2 → 2.0.3

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.0.3",
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;
@@ -105,11 +105,25 @@ 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
129
  auth_method: 'oauth',
@@ -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,64 @@ 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
+ handler: wrap(async (argv, app) => {
34
+ const name = argv.name;
35
+ if (app.config.profiles && app.config.profiles[name]) {
36
+ throw new Error(`Profile "${name}" already exists. Use "jsn profiles update" or remove it first.`);
37
+ }
38
+
39
+ let instance = argv.instance;
40
+ if (!instance) {
41
+ throw new Error(`Instance URL is required.\n\n jsn profiles create ${name} --instance https://dev12345.service-now.com`);
42
+ }
43
+ instance = normalizeInstanceURL(instance);
44
+
45
+ const profile = { instance_url: instance };
46
+
47
+ // If username/password provided, save credentials too
48
+ if (argv.username && argv.password) {
49
+ const { saveCredentials } = await import('../auth.js');
50
+ saveCredentials(instance, {
51
+ auth_method: 'basic',
52
+ username: argv.username,
53
+ password: argv.password,
54
+ });
55
+ profile.auth_method = 'basic';
56
+ profile.username = argv.username;
57
+ }
58
+
59
+ await setProfile(app.config, name, profile);
60
+
61
+ app.ok({
62
+ profile: name,
63
+ instance,
64
+ auth_method: profile.auth_method || 'oauth (not yet logged in)',
65
+ }, { summary: `Profile "${name}" created for ${instance}` });
66
+ }),
67
+ })
10
68
  .command({
11
69
  command: 'list',
12
70
  describe: 'List all profiles',
@@ -78,8 +136,9 @@ export function profilesCmd(wrap) {
78
136
  // When no subcommand is given, show help
79
137
  if (!argv._[1]) {
80
138
  console.log('Manage your ServiceNow instance profiles.\n');
81
- console.log('Usage: jsn profiles <list|show|use|remove> [options]\n');
139
+ console.log('Usage: jsn profiles <create|list|show|use|remove> [options]\n');
82
140
  console.log('Commands:');
141
+ console.log(' create <name> Create a new profile (--instance required)');
83
142
  console.log(' list List all profiles');
84
143
  console.log(' show [name] Show profile details');
85
144
  console.log(' use <name> Switch to a different profile');
package/src/output.js CHANGED
@@ -176,11 +176,28 @@ 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
+ if (p.name) {
196
+ this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${verifiedStr}${staleStr}\n`);
197
+ } else if (p.username) {
198
+ this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${verifiedStr}${staleStr}\n`);
182
199
  } else {
183
- this.writer.write(`${prefix}${status} ${p.instance}\n`);
200
+ this.writer.write(`${prefix}${authIcon} ${p.instance}${verifiedStr}${staleStr}\n`);
184
201
  }
185
202
  }
186
203
  }
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') {