@jacebenson/jsn 2.0.3 → 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.3",
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
@@ -544,3 +544,5 @@ export class AuthManager {
544
544
  deleteCredentials(instance);
545
545
  }
546
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'
@@ -126,7 +126,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
126
126
 
127
127
  app.config.profiles[profileName] = {
128
128
  instance_url: instanceURL,
129
- auth_method: 'oauth',
129
+ auth_method: argv.password ? 'basic' : 'oauth',
130
130
  username: username || undefined,
131
131
  };
132
132
 
@@ -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;
@@ -56,6 +61,11 @@ export function profilesCmd(wrap) {
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({
@@ -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
@@ -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
  }