@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 +1 -1
- package/src/auth.js +2 -0
- package/src/cli.js +19 -0
- package/src/commands/auth.js +1 -1
- package/src/commands/profiles.js +30 -2
- package/src/commands/setup.js +41 -5
- package/src/mutations.js +52 -0
- package/src/output.js +6 -3
package/package.json
CHANGED
package/src/auth.js
CHANGED
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'
|
package/src/commands/auth.js
CHANGED
|
@@ -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
|
|
package/src/commands/profiles.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
138
|
+
const readOnlyNote = profile.read_only ? ' [READ-ONLY]' : '';
|
|
139
|
+
app.ok({ name, ...profile }, { summary: `Profile: ${name}${readOnlyNote}` });
|
|
112
140
|
}),
|
|
113
141
|
})
|
|
114
142
|
.command({
|
package/src/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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();
|
package/src/mutations.js
ADDED
|
@@ -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
|
}
|