@jacebenson/jsn 2.0.3 → 3.0.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.3",
3
+ "version": "3.0.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/app.js CHANGED
@@ -33,6 +33,14 @@ export class App {
33
33
  }
34
34
 
35
35
  loadContext() {
36
+ const activeName = this.config.activeProfile || this.config.defaultProfile;
37
+ if (activeName && this.config.profiles[activeName]) {
38
+ this.context.profileName = activeName;
39
+ this.context.username = this.config.profiles[activeName].username || '';
40
+ return;
41
+ }
42
+
43
+ // Fallback: no active profile name — find by instance URL (legacy config)
36
44
  const instance = getEffectiveInstance(this.config);
37
45
  if (!instance) return;
38
46
  this.context.profileName = extractProfileName(instance);
package/src/auth.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // OAuth 2.0 with PKCE authentication
2
2
  // Credentials are stored in the OS keyring (shared with Go version via libsecret/secret-tool)
3
3
  // Falls back to file-based storage when keyring is unavailable.
4
+ // Credentials are keyed by <username>@<instance> so different users on the
5
+ // same instance have separate credential slots.
4
6
 
5
7
  import fs from 'node:fs';
6
8
  import path from 'node:path';
@@ -10,6 +12,18 @@ import { execSync } from 'node:child_process';
10
12
  import { globalConfigDir, normalizeInstanceURL } from './config.js';
11
13
  import { errAuth } from './errors.js';
12
14
 
15
+ // ─── Credential key helpers ───
16
+
17
+ /**
18
+ * Build a compound key for credential storage: <username>@<instance>
19
+ * When username is omitted (legacy), just the normalized instance URL is used.
20
+ */
21
+ function credKey(instance, username) {
22
+ const normalized = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_');
23
+ if (!username) return normalized;
24
+ return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
25
+ }
26
+
13
27
  // ─── PKCE state persistence (shared with Go version) ───
14
28
 
15
29
  function pkceStatePath(instance) {
@@ -60,7 +74,7 @@ function getBasicAuthFromEnv(instance) {
60
74
  // Try global env vars
61
75
  const username = process.env.SN_USERNAME;
62
76
  const password = process.env.SN_PASSWORD;
63
- if (username && password) return { auth_method: 'basic', username, password };
77
+ if (username && password) return { auth_method: 'basic', username, password, auth_source: 'env_basic' };
64
78
  return null;
65
79
  }
66
80
 
@@ -68,12 +82,12 @@ function getBasicAuthFromEnv(instance) {
68
82
  const host = envVarName(instance);
69
83
  const instanceUser = process.env[`SN_${host}_USERNAME`];
70
84
  const instancePass = process.env[`SN_${host}_PASSWORD`];
71
- if (instanceUser && instancePass) return { auth_method: 'basic', username: instanceUser, password: instancePass };
85
+ if (instanceUser && instancePass) return { auth_method: 'basic', username: instanceUser, password: instancePass, auth_source: 'env_basic' };
72
86
 
73
87
  // Fall back to global env vars
74
88
  const globalUser = process.env.SN_USERNAME;
75
89
  const globalPass = process.env.SN_PASSWORD;
76
- if (globalUser && globalPass) return { auth_method: 'basic', username: globalUser, password: globalPass };
90
+ if (globalUser && globalPass) return { auth_method: 'basic', username: globalUser, password: globalPass, auth_source: 'env_basic' };
77
91
 
78
92
  return null;
79
93
  }
@@ -83,11 +97,11 @@ const KEYRING_SERVICE = 'servicenow-cli';
83
97
  const KEYRING_ATTR_SERVICE = 'service';
84
98
  const KEYRING_ATTR_USERNAME = 'username';
85
99
 
86
- function credentialsPath(instance) {
100
+ function credentialsPath(key) {
87
101
  const dir = path.join(globalConfigDir(), 'credentials');
88
102
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
89
103
  // Match Go's filename encoding: replace :// and / and : with _
90
- const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
104
+ const filename = key + '.json';
91
105
  return path.join(dir, filename);
92
106
  }
93
107
 
@@ -117,17 +131,19 @@ function buildAuthURL(instanceURL, clientID, pkce) {
117
131
 
118
132
  // ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
119
133
 
120
- function keyringLookup(instance) {
134
+ function keyringLookup(key) {
121
135
  try {
122
136
  const result = execSync(
123
- `secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
137
+ `secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
124
138
  { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
125
139
  );
126
140
  const trimmed = result.trim();
127
141
  if (!trimmed) return null;
128
142
  const parsed = JSON.parse(trimmed);
129
- // Normalize field names from Go's format to Node.js format
143
+ // Spread all fields from keyring first, then normalize known fields
144
+ // so username, password, and other extras are preserved.
130
145
  return {
146
+ ...parsed,
131
147
  auth_method: parsed.auth_method || 'oauth',
132
148
  access_token: parsed.access_token || parsed.AccessToken || '',
133
149
  refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
@@ -139,11 +155,11 @@ function keyringLookup(instance) {
139
155
  }
140
156
  }
141
157
 
142
- function keyringStore(instance, creds) {
158
+ function keyringStore(key, creds) {
143
159
  try {
144
160
  execSync(
145
- `secret-tool store --label="Password for '${instance}' on '${KEYRING_SERVICE}'" ` +
146
- `${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
161
+ `secret-tool store --label="Password for '${key}' on '${KEYRING_SERVICE}'" ` +
162
+ `${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
147
163
  { stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
148
164
  );
149
165
  return true;
@@ -152,10 +168,10 @@ function keyringStore(instance, creds) {
152
168
  }
153
169
  }
154
170
 
155
- function keyringDelete(instance) {
171
+ function keyringDelete(key) {
156
172
  try {
157
173
  execSync(
158
- `secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
174
+ `secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
159
175
  { stdio: 'ignore' }
160
176
  );
161
177
  } catch {
@@ -165,34 +181,45 @@ function keyringDelete(instance) {
165
181
 
166
182
  // ─── File-based storage (fallback) ───
167
183
 
168
- function loadCredentials(instance) {
169
- // Try keyring first (shared with Go version)
170
- const keyringCreds = keyringLookup(instance);
171
- if (keyringCreds) return keyringCreds;
172
-
173
- // Fall back to file-based storage
184
+ /**
185
+ * Load credentials for an instance keyed by <username>@<instance>.
186
+ * When username is omitted (legacy configs without profile user), uses
187
+ * bare instance key for lookup.
188
+ */
189
+ function loadCredentials(instance, username) {
190
+ const key = username ? credKey(instance, username) : credKey(instance);
191
+ const keyringCreds = keyringLookup(key);
192
+ if (keyringCreds) {
193
+ return { ...keyringCreds, auth_source: 'keyring' };
194
+ }
174
195
  try {
175
- const data = fs.readFileSync(credentialsPath(instance), 'utf-8');
176
- return JSON.parse(data);
196
+ const data = fs.readFileSync(credentialsPath(key), 'utf-8');
197
+ const creds = JSON.parse(data);
198
+ return { ...creds, auth_source: 'file' };
177
199
  } catch {
178
200
  return null;
179
201
  }
180
202
  }
181
203
 
182
- function saveCredentials(instance, creds) {
204
+ /**
205
+ * Save credentials for an instance.
206
+ * Uses <username>@<instance> key when username is available.
207
+ */
208
+ function saveCredentials(instance, creds, username) {
209
+ const key = username ? credKey(instance, username) : credKey(instance);
183
210
  // Stamp last_seen on every credential save
184
211
  creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
185
212
  // Try keyring first, fall back to file
186
- if (!keyringStore(instance, creds)) {
187
- fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
213
+ if (!keyringStore(key, creds)) {
214
+ fs.writeFileSync(credentialsPath(key), JSON.stringify(creds, null, 2), { mode: 0o600 });
188
215
  }
189
216
  }
190
217
 
191
218
  /**
192
219
  * Get the last_seen timestamp for an instance, if available.
193
220
  */
194
- function getLastSeen(instance) {
195
- const creds = loadCredentials(instance);
221
+ function getLastSeen(instance, username) {
222
+ const creds = loadCredentials(instance, username);
196
223
  if (!creds || !creds.last_seen) return null;
197
224
  return creds.last_seen;
198
225
  }
@@ -200,17 +227,21 @@ function getLastSeen(instance) {
200
227
  /**
201
228
  * Update the last_seen timestamp for an instance to now.
202
229
  */
203
- function touchLastSeen(instance) {
204
- const creds = loadCredentials(instance);
230
+ function touchLastSeen(instance, username) {
231
+ const creds = loadCredentials(instance, username);
205
232
  if (!creds) return;
206
233
  creds.last_seen = Math.floor(Date.now() / 1000);
207
- saveCredentials(instance, creds);
234
+ saveCredentials(instance, creds, username);
208
235
  }
209
236
 
210
- function deleteCredentials(instance) {
211
- keyringDelete(instance);
237
+ /**
238
+ * Delete credentials for an instance keyed by <username>@<instance>.
239
+ */
240
+ function deleteCredentials(instance, username) {
241
+ const key = username ? credKey(instance, username) : credKey(instance);
242
+ keyringDelete(key);
212
243
  try {
213
- fs.unlinkSync(credentialsPath(instance));
244
+ fs.unlinkSync(credentialsPath(key));
214
245
  } catch {
215
246
  // ignore
216
247
  }
@@ -271,12 +302,58 @@ export class AuthManager {
271
302
  this.httpClient = { timeout: 30000 };
272
303
  }
273
304
 
305
+ /**
306
+ * Resolve the username for credential keying from the active profile.
307
+ * Returns null when no profile is configured — callers fall back to bare
308
+ * instance key for backward compatibility with legacy / no-profile usage.
309
+ */
310
+ _activeUsername() {
311
+ const cfg = this.configProvider.config;
312
+ const name = cfg.activeProfile || cfg.defaultProfile;
313
+ if (name && cfg.profiles[name] && cfg.profiles[name].username) {
314
+ return cfg.profiles[name].username;
315
+ }
316
+ return null;
317
+ }
318
+
274
319
  getLastSeen(instance) {
275
- return getLastSeen(instance);
320
+ return getLastSeen(instance, this._activeUsername());
276
321
  }
277
322
 
278
323
  touchLastSeen(instance) {
279
- return touchLastSeen(instance);
324
+ return touchLastSeen(instance, this._activeUsername());
325
+ }
326
+
327
+ /**
328
+ * Check if legacy bare-instance credentials exist (stored before
329
+ * user@instance keying). Returns true when old-style creds are found
330
+ * but wouldn't be picked up by the current username-scoped lookup.
331
+ */
332
+ hasLegacyCredentials(instance) {
333
+ if (!instance) return false;
334
+ const bareCreds = loadCredentials(instance);
335
+ if (!bareCreds) return false;
336
+ // If active profile has a username, the bare key won't be found
337
+ // by loadCredentials(instance, username). That's the legacy case.
338
+ const username = this._activeUsername();
339
+ if (!username) return false; // No username set — bare key IS the active path
340
+ const userCreds = loadCredentials(instance, username);
341
+ return !!bareCreds && !userCreds;
342
+ }
343
+
344
+ /**
345
+ * Return the auth source for an instance without throwing.
346
+ * Mirrors the auth resolution order in getCredentials().
347
+ * For older stored credentials without auth_source, falls back
348
+ * to the saved auth_method field.
349
+ */
350
+ getAuthSource(instance) {
351
+ if (process.env.SERVICENOW_OAUTH_TOKEN) return 'env_token';
352
+ if (getBasicAuthFromEnv(instance)) return 'env_basic';
353
+ const creds = loadCredentials(instance, this._activeUsername());
354
+ if (!creds) return null;
355
+ // New creds have auth_source; older ones only have auth_method
356
+ return creds.auth_source || creds.auth_method || 'stored';
280
357
  }
281
358
 
282
359
  isAuthenticated() {
@@ -295,7 +372,7 @@ export class AuthManager {
295
372
  isAuthenticatedFor(instance) {
296
373
  if (!instance) return false;
297
374
  if (getBasicAuthFromEnv(instance)) return true;
298
- const creds = loadCredentials(instance);
375
+ const creds = loadCredentials(instance, this._activeUsername());
299
376
  if (!creds) return false;
300
377
  if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
301
378
  return !!creds.access_token;
@@ -303,7 +380,7 @@ export class AuthManager {
303
380
 
304
381
  async getCredentials() {
305
382
  if (process.env.SERVICENOW_OAUTH_TOKEN) {
306
- return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN };
383
+ return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN, auth_source: 'env_token' };
307
384
  }
308
385
  const instance = this.configProvider.getEffectiveInstance();
309
386
  if (!instance) {
@@ -319,7 +396,7 @@ export class AuthManager {
319
396
  // Check basic auth from env vars first
320
397
  const basicCreds = getBasicAuthFromEnv(instance);
321
398
  if (basicCreds) return basicCreds;
322
- const creds = loadCredentials(instance);
399
+ const creds = loadCredentials(instance, this._activeUsername());
323
400
  if (!creds) {
324
401
  throw errAuth(`Not authenticated for ${instance}`);
325
402
  }
@@ -377,7 +454,8 @@ export class AuthManager {
377
454
 
378
455
  console.log('\nExchanging authorization code for tokens...');
379
456
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
380
- saveCredentials(instanceURL, newCreds);
457
+ // Save with username for per-user credential keying
458
+ saveCredentials(instanceURL, newCreds, newCreds.username);
381
459
  return newCreds;
382
460
  }
383
461
 
@@ -414,7 +492,7 @@ export class AuthManager {
414
492
  console.log(`\nAuthorization code found in ${filePath}`);
415
493
  removePKCEState(instanceURL);
416
494
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
417
- saveCredentials(instanceURL, newCreds);
495
+ saveCredentials(instanceURL, newCreds, newCreds.username);
418
496
  console.log('Token exchange successful!\n');
419
497
  return newCreds;
420
498
  }
@@ -444,7 +522,7 @@ export class AuthManager {
444
522
  ` jsn auth login --password ${instanceURL}`
445
523
  );
446
524
  }
447
- saveCredentials(instanceURL, creds);
525
+ saveCredentials(instanceURL, creds, creds.username);
448
526
  console.log(`✓ Basic auth credentials saved for ${instanceURL}`);
449
527
  return creds;
450
528
  }
@@ -469,7 +547,7 @@ export class AuthManager {
469
547
  removePKCEState(instanceURL);
470
548
 
471
549
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
472
- saveCredentials(instanceURL, newCreds);
550
+ saveCredentials(instanceURL, newCreds, newCreds.username);
473
551
  return newCreds;
474
552
  }
475
553
 
@@ -501,6 +579,8 @@ export class AuthManager {
501
579
  refresh_token: tokenResp.refresh_token,
502
580
  expires_at: expiresAt,
503
581
  created_at: Math.floor(Date.now() / 1000),
582
+ // The username will be set after post-login verification in the auth command
583
+ auth_source: 'oauth',
504
584
  };
505
585
  }
506
586
 
@@ -529,11 +609,12 @@ export class AuthManager {
529
609
  access_token: tokenResp.access_token,
530
610
  refresh_token: tokenResp.refresh_token,
531
611
  created_at: Math.floor(Date.now() / 1000),
612
+ auth_source: 'oauth',
532
613
  };
533
614
  if (tokenResp.expires_in) {
534
615
  newCreds.expires_at = Math.floor(Date.now() / 1000) + tokenResp.expires_in;
535
616
  }
536
- saveCredentials(instance, newCreds);
617
+ saveCredentials(instance, newCreds, creds.username);
537
618
  return newCreds;
538
619
  }
539
620
 
@@ -541,6 +622,8 @@ export class AuthManager {
541
622
  if (!instance) {
542
623
  throw errAuth('No instance specified');
543
624
  }
544
- deleteCredentials(instance);
625
+ deleteCredentials(instance, this._activeUsername());
545
626
  }
546
627
  }
628
+
629
+ export { saveCredentials, loadCredentials, deleteCredentials, 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'
@@ -125,11 +125,25 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
125
125
  }
126
126
 
127
127
  app.config.profiles[profileName] = {
128
+ ...(app.config.profiles[profileName] || {}),
128
129
  instance_url: instanceURL,
129
- auth_method: 'oauth',
130
+ auth_method: argv.password ? 'basic' : 'oauth',
130
131
  username: username || undefined,
131
132
  };
132
133
 
134
+ // Re-save OAuth credentials with username now that we have it,
135
+ // so they're keyed by <user>@<instance> for per-user isolation.
136
+ // Also clean up the old bare-instance key so migration is clean.
137
+ if (!argv.password && username) {
138
+ const { saveCredentials, loadCredentials, deleteCredentials } = await import('../auth.js');
139
+ const stored = loadCredentials(instanceURL);
140
+ if (stored && stored.access_token) {
141
+ stored.username = username;
142
+ saveCredentials(instanceURL, stored, username);
143
+ deleteCredentials(instanceURL); // remove bare-instance legacy key
144
+ }
145
+ }
146
+
133
147
  // Set as default if this is the first one
134
148
  const setDefault = !app.config.instanceURL && !app.config.defaultProfile;
135
149
  if (setDefault) {
@@ -188,6 +202,8 @@ Examples:
188
202
  const instance = profile.instance_url;
189
203
  const isAuth = app.auth.isAuthenticatedFor(instance);
190
204
  const lastSeen = app.auth.getLastSeen(instance);
205
+ const authSource = isAuth ? app.auth.getAuthSource(instance) : null;
206
+ const legacy = !isAuth && app.auth.hasLegacyCredentials(instance) ? true : undefined;
191
207
 
192
208
  // Try live verification
193
209
  let verified = null;
@@ -216,6 +232,8 @@ Examples:
216
232
  name,
217
233
  instance,
218
234
  authenticated: isAuth,
235
+ legacy,
236
+ auth_source: authSource || (legacy ? 'legacy' : undefined),
219
237
  verified,
220
238
  verified_as: verifiedAt || undefined,
221
239
  last_seen: lastSeen || undefined,
@@ -233,6 +251,20 @@ Examples:
233
251
  };
234
252
 
235
253
  app.ok(result, { summary: `${profiles.length} profile(s)` });
254
+
255
+ // Warn about legacy credentials that need re-authentication
256
+ const legacyProfiles = profiles.filter(p => p.legacy);
257
+ if (legacyProfiles.length > 0) {
258
+ process.stderr.write(
259
+ '\n⚠️ This release changed how credentials are stored.\n' +
260
+ ' Some profiles still use the old format.\n' +
261
+ ' Run the following to re-authenticate:\n'
262
+ );
263
+ for (const p of legacyProfiles) {
264
+ process.stderr.write(` jsn auth login ${p.name}\n`);
265
+ }
266
+ process.stderr.write('\n');
267
+ }
236
268
  }),
237
269
  })
238
270
  .command({
@@ -29,6 +29,11 @@ export function profilesCmd(wrap) {
29
29
  alias: 'p',
30
30
  describe: 'Password for basic auth',
31
31
  type: 'string',
32
+ })
33
+ .option('read-only', {
34
+ describe: 'Mark profile as read-only (blocks mutation commands)',
35
+ type: 'boolean',
36
+ default: false,
32
37
  }),
33
38
  handler: wrap(async (argv, app) => {
34
39
  const name = argv.name;
@@ -51,11 +56,16 @@ export function profilesCmd(wrap) {
51
56
  auth_method: 'basic',
52
57
  username: argv.username,
53
58
  password: argv.password,
54
- });
59
+ }, argv.username);
55
60
  profile.auth_method = 'basic';
56
61
  profile.username = argv.username;
57
62
  }
58
63
 
64
+ // If read-only flag is set, mark the profile
65
+ if (argv['read-only']) {
66
+ profile.read_only = true;
67
+ }
68
+
59
69
  await setProfile(app.config, name, profile);
60
70
 
61
71
  app.ok({
@@ -80,9 +90,26 @@ export function profilesCmd(wrap) {
80
90
  username: profile.username || '',
81
91
  authenticated: isAuth,
82
92
  default: isDefault,
93
+ read_only: profile.read_only || false,
83
94
  });
84
95
  }
85
- 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 });
86
113
  }),
87
114
  })
88
115
  .command({
@@ -108,7 +135,8 @@ export function profilesCmd(wrap) {
108
135
  throw new Error('No profiles configured');
109
136
  }
110
137
  const profile = app.config.profiles[name];
111
- app.ok({ name, ...profile }, { summary: `Profile: ${name}` });
138
+ const readOnlyNote = profile.read_only ? ' [READ-ONLY]' : '';
139
+ app.ok({ name, ...profile }, { summary: `Profile: ${name}${readOnlyNote}` });
112
140
  }),
113
141
  })
114
142
  .command({
@@ -119,6 +147,7 @@ export function profilesCmd(wrap) {
119
147
  if (!app.config.profiles[name]) {
120
148
  throw new Error(`Profile not found: ${name}`);
121
149
  }
150
+ const instance = app.config.profiles[name].instance_url;
122
151
  delete app.config.profiles[name];
123
152
  if (app.config.defaultProfile === name) {
124
153
  app.config.defaultProfile = '';
@@ -127,6 +156,14 @@ export function profilesCmd(wrap) {
127
156
  app.config.activeProfile = '';
128
157
  }
129
158
  saveConfig(app.config);
159
+
160
+ // Only clean up credentials if no other profile uses this instance URL
161
+ const stillInUse = instance && Object.values(app.config.profiles || {})
162
+ .some(p => p.instance_url === instance);
163
+ if (instance && !stillInUse) {
164
+ app.auth.logout(instance);
165
+ }
166
+
130
167
  app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
131
168
  }),
132
169
  })
@@ -5,6 +5,14 @@ export function setupCmd(wrap) {
5
5
  return {
6
6
  command: 'setup',
7
7
  describe: 'Interactive first-time setup',
8
+ builder: (yargs) => {
9
+ return yargs
10
+ .option('read-only', {
11
+ describe: 'Mark profile as read-only (blocks mutation commands)',
12
+ type: 'boolean',
13
+ default: false,
14
+ });
15
+ },
8
16
  handler: wrap(async (_argv, app) => {
9
17
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
18
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
@@ -19,17 +27,69 @@ export function setupCmd(wrap) {
19
27
  }
20
28
  console.log(`Instance: ${instance}`);
21
29
 
22
- const profileName = await ask('Profile name (default): ') || 'default';
23
- await setProfile(app.config, profileName, { instance_url: instance });
30
+ // Warn if another profile already uses this instance URL
31
+ const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
32
+ if (existing) {
33
+ console.log(`Note: instance is also used by profile "${existing[0]}".`);
34
+ }
35
+
36
+ const profileName = (await ask('Profile name (default): ')) || 'default';
37
+ const profile = { instance_url: instance };
38
+ if (_argv['read-only']) {
39
+ profile.read_only = true;
40
+ }
41
+
42
+ const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
43
+ const useBasic = authMethod.toLowerCase().startsWith('b');
44
+
45
+ if (useBasic) {
46
+ const username = await ask('Username: ');
47
+ rl.close(); // Close outer readline before askHidden creates its own
48
+ const { loadCredentials, askHidden } = await import('../auth.js');
49
+ const existing = loadCredentials(instance) || loadCredentials(instance, username);
50
+ if (existing && existing.auth_method === 'basic') {
51
+ console.log(`Using existing credentials for ${instance}`);
52
+ } else {
53
+ const password = await askHidden('Password: ');
54
+ const { saveCredentials } = await import('../auth.js');
55
+ saveCredentials(instance, { auth_method: 'basic', username, password }, username);
56
+ profile.username = username;
57
+ console.log('Basic auth credentials saved');
58
+ }
59
+ profile.auth_method = 'basic';
60
+ } else {
61
+ profile.auth_method = 'oauth';
62
+ }
63
+
64
+ await setProfile(app.config, profileName, profile);
24
65
  // Set as active profile so subsequent commands know which instance to use
25
66
  app.config.activeProfile = profileName;
26
67
  app.config.defaultProfile = profileName;
27
68
  saveConfig(app.config);
28
69
 
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!');
70
+ if (!useBasic) {
71
+ const loginNow = await ask('Login now? [Y/n]: ');
72
+ if (!loginNow || loginNow.toLowerCase() !== 'n') {
73
+ await app.auth.login(instance);
74
+ // Re-save credentials under <user>@<instance> after verifying username
75
+ try {
76
+ const { SDKClient } = await import('./sdk.js');
77
+ const sdk = new SDKClient(instance, app.auth);
78
+ const user = await sdk.getCurrentUser();
79
+ if (user && user.user_name) {
80
+ const { loadCredentials, saveCredentials } = await import('../auth.js');
81
+ const stored = loadCredentials(instance);
82
+ if (stored && stored.access_token) {
83
+ stored.username = user.user_name;
84
+ saveCredentials(instance, stored, user.user_name);
85
+ }
86
+ profile.username = user.user_name;
87
+ }
88
+ } catch {
89
+ // Non-fatal — token saved, username will be set on next auth login
90
+ }
91
+ console.log('Login successful!');
92
+ }
33
93
  }
34
94
 
35
95
  rl.close();
package/src/config.js CHANGED
@@ -120,6 +120,22 @@ export function saveConfig(cfg) {
120
120
  format: cfg.format || undefined,
121
121
  };
122
122
  fs.writeFileSync(globalConfigPath(), JSON.stringify(payload, null, 2), { mode: 0o600 });
123
+
124
+ // Sync managed fields to local config if it exists, so profile operations
125
+ // (use, remove, create, auth login) aren't silently reverted on next load.
126
+ if (fs.existsSync(localConfigPath())) {
127
+ try {
128
+ const localData = JSON.parse(fs.readFileSync(localConfigPath(), 'utf-8'));
129
+ const merged = { ...localData };
130
+ if (payload.profiles !== undefined) merged.profiles = payload.profiles;
131
+ if (payload.default_profile !== undefined) merged.default_profile = payload.default_profile;
132
+ if (payload.instance_url !== undefined) merged.instance_url = payload.instance_url;
133
+ if (payload.format !== undefined) merged.format = payload.format;
134
+ fs.writeFileSync(localConfigPath(), JSON.stringify(merged, null, 2), { mode: 0o600 });
135
+ } catch {
136
+ // Local config unreadable — skip sync, global save already succeeded
137
+ }
138
+ }
123
139
  }
124
140
 
125
141
  export function saveLocalConfig(cfg) {
@@ -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
@@ -192,12 +192,15 @@ export class OutputWriter {
192
192
  staleStr = ` (${p.days_since_last_seen}d ago — may have been released)`;
193
193
  }
194
194
 
195
+ // Show lock icon for read-only profiles
196
+ const lockIcon = p.read_only ? ' 🔒' : '';
197
+
195
198
  if (p.name) {
196
- this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${verifiedStr}${staleStr}\n`);
199
+ this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
197
200
  } else if (p.username) {
198
- this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${verifiedStr}${staleStr}\n`);
201
+ this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${verifiedStr}${staleStr}\n`);
199
202
  } else {
200
- this.writer.write(`${prefix}${authIcon} ${p.instance}${verifiedStr}${staleStr}\n`);
203
+ this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
201
204
  }
202
205
  }
203
206
  }