@jacebenson/jsn 3.4.3 → 4.0.1
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/README.md +5 -4
- package/package.json +3 -1
- package/skills/servicenow/SKILL.md +3 -3
- package/src/app.js +7 -7
- package/src/auth.js +34 -63
- package/src/cli.js +27 -20
- package/src/commands/_ticket.js +7 -1
- package/src/commands/auth.js +303 -40
- package/src/commands/catalog.js +3 -4
- package/src/commands/dev/_generic.js +7 -14
- package/src/commands/dev/flows.js +110 -2
- package/src/commands/dev/forms.js +0 -11
- package/src/commands/dev/import.js +1 -1
- package/src/commands/dev/lists.js +2 -2
- package/src/commands/dev/updatesets.js +0 -16
- package/src/commands/docs/community-sources.json +18 -0
- package/src/commands/docs/community.js +343 -0
- package/src/commands/docs/db.js +38 -2
- package/src/commands/docs/docs.js +41 -0
- package/src/commands/docs/ingest.js +4 -17
- package/src/commands/docs/refresh.js +4 -17
- package/src/commands/docs/search.js +3 -3
- package/src/commands/docs/serve.js +2 -2
- package/src/commands/grouproles.js +5 -1
- package/src/commands/groups.js +6 -3
- package/src/commands/records.js +5 -2
- package/src/commands/requests.js +2 -1
- package/src/commands/setup.js +10 -84
- package/src/commands/skill.js +5 -7
- package/src/commands/tickets.js +4 -1
- package/src/commands/users.js +4 -1
- package/src/config.js +9 -0
- package/src/errors.js +26 -0
- package/src/help.js +2 -4
- package/src/helpers.js +65 -8
- package/src/mutations.js +40 -9
- package/src/output.js +6 -3
- package/src/paginated-search.js +1 -1
- package/src/records/inspect.js +2 -1
- package/src/commands/profiles.js +0 -190
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// CLI search against the docs SQLite database.
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
5
|
import { getDocsDbPath, hasEmbeddings } from './db.js';
|
|
6
6
|
import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
|
|
7
7
|
|
|
@@ -24,7 +24,7 @@ export function searchDocs(opts = {}) {
|
|
|
24
24
|
throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const db = new
|
|
27
|
+
const db = new Database(dbPath, { readonly: true });
|
|
28
28
|
const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
|
|
29
29
|
const offset = parseInt(opts.offset, 10) || 0;
|
|
30
30
|
const bundle = opts.bundle ? String(opts.bundle) : null;
|
|
@@ -46,7 +46,7 @@ export function searchDocs(opts = {}) {
|
|
|
46
46
|
|
|
47
47
|
// Total matching docs (for pagination)
|
|
48
48
|
const total = db
|
|
49
|
-
.prepare(`SELECT COUNT(*) AS n FROM docs_fts WHERE docs_fts MATCH @q${where}`)
|
|
49
|
+
.prepare(`SELECT COUNT(*) AS n FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid WHERE docs_fts MATCH @q${where}`)
|
|
50
50
|
.get({ q, ...(bundle && { bundle }), ...(docType && { docType }) }).n;
|
|
51
51
|
|
|
52
52
|
let rows;
|
|
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import http from 'node:http';
|
|
6
6
|
import { URL } from 'node:url';
|
|
7
|
-
import
|
|
7
|
+
import Database from 'better-sqlite3';
|
|
8
8
|
import { getDocsDbPath, hasEmbeddings } from './db.js';
|
|
9
9
|
import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
|
|
10
10
|
|
|
@@ -72,7 +72,7 @@ export function serveDocs(opts = {}) {
|
|
|
72
72
|
throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const db = new
|
|
75
|
+
const db = new Database(dbPath, { readonly: true });
|
|
76
76
|
const hasEmb = hasEmbeddings(db);
|
|
77
77
|
const hrrDim = (() => {
|
|
78
78
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export function groupRolesCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -42,6 +42,8 @@ export function groupRolesCmd(wrap) {
|
|
|
42
42
|
handler: wrap(async (argv, app) => {
|
|
43
43
|
const groupName = argv.group;
|
|
44
44
|
const roleName = argv.role;
|
|
45
|
+
assertSafeExactMatch(groupName);
|
|
46
|
+
assertSafeExactMatch(roleName);
|
|
45
47
|
|
|
46
48
|
// Resolve group sys_id
|
|
47
49
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
|
@@ -100,6 +102,8 @@ export function groupRolesCmd(wrap) {
|
|
|
100
102
|
handler: wrap(async (argv, app) => {
|
|
101
103
|
const groupName = argv.group;
|
|
102
104
|
const roleName = argv.role;
|
|
105
|
+
assertSafeExactMatch(groupName);
|
|
106
|
+
assertSafeExactMatch(roleName);
|
|
103
107
|
|
|
104
108
|
// Resolve group sys_id
|
|
105
109
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
package/src/commands/groups.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export function groupsCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -32,13 +32,13 @@ export function groupsCmd(wrap) {
|
|
|
32
32
|
cp.set('sysparm_query', `group=${sysID}`); cp.set('sysparm_limit', '0'); cp.set('sysparm_fields', 'sys_id');
|
|
33
33
|
const members = await app.sdk.list('sys_user_grmember', cp);
|
|
34
34
|
memberCount = members.length;
|
|
35
|
-
} catch {}
|
|
35
|
+
} catch { /* non-critical */ }
|
|
36
36
|
try {
|
|
37
37
|
const rp = new URLSearchParams();
|
|
38
38
|
rp.set('sysparm_query', `group=${sysID}`); rp.set('sysparm_limit', '0'); rp.set('sysparm_fields', 'sys_id');
|
|
39
39
|
const roles = await app.sdk.list('sys_group_has_role', rp);
|
|
40
40
|
roleCount = roles.length;
|
|
41
|
-
} catch {}
|
|
41
|
+
} catch { /* non-critical */ }
|
|
42
42
|
|
|
43
43
|
picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user_group' };
|
|
44
44
|
return app.ok(picked, {
|
|
@@ -72,6 +72,7 @@ export function groupsCmd(wrap) {
|
|
|
72
72
|
describe: 'Show a group by name or sys_id',
|
|
73
73
|
handler: wrap(async (argv, app) => {
|
|
74
74
|
const id = argv.name;
|
|
75
|
+
assertSafeExactMatch(id);
|
|
75
76
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
76
77
|
const params = new URLSearchParams();
|
|
77
78
|
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
|
|
@@ -117,6 +118,7 @@ export function groupsCmd(wrap) {
|
|
|
117
118
|
.option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
|
|
118
119
|
handler: wrap(async (argv, app) => {
|
|
119
120
|
const id = argv.name;
|
|
121
|
+
assertSafeExactMatch(id);
|
|
120
122
|
const recordData = JSON.parse(argv.data);
|
|
121
123
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
122
124
|
const params = new URLSearchParams();
|
|
@@ -142,6 +144,7 @@ export function groupsCmd(wrap) {
|
|
|
142
144
|
describe: 'Delete a group by name or sys_id',
|
|
143
145
|
handler: wrap(async (argv, app) => {
|
|
144
146
|
const id = argv.name;
|
|
147
|
+
assertSafeExactMatch(id);
|
|
145
148
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
146
149
|
const params = new URLSearchParams();
|
|
147
150
|
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
|
package/src/commands/records.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
const tableDefaultColumns = {
|
|
4
4
|
incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
|
|
@@ -115,6 +115,7 @@ export function recordsCmd(wrap) {
|
|
|
115
115
|
.option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
|
|
116
116
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' }),
|
|
117
117
|
handler: wrap(async (argv, app) => {
|
|
118
|
+
assertSafeExactMatch(argv['sys-id']);
|
|
118
119
|
const params = new URLSearchParams();
|
|
119
120
|
params.set('sysparm_query', `sys_id=${argv['sys-id']}`);
|
|
120
121
|
params.set('sysparm_limit', '1');
|
|
@@ -169,8 +170,10 @@ export function recordsCmd(wrap) {
|
|
|
169
170
|
describe: 'Delete a record',
|
|
170
171
|
builder: (y) => y
|
|
171
172
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
172
|
-
.option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
|
|
173
|
+
.option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
|
|
174
|
+
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
173
175
|
handler: wrap(async (argv, app) => {
|
|
176
|
+
await confirmDelete(app, argv, `Delete record from ${argv.table} (${argv['sys-id']})`);
|
|
174
177
|
await app.sdk.delete(argv.table, argv['sys-id']);
|
|
175
178
|
app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
|
|
176
179
|
}),
|
package/src/commands/requests.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildTicketCommands } from './_ticket.js';
|
|
2
|
-
import { getStringField } from '../helpers.js';
|
|
2
|
+
import { getStringField, assertSafeExactMatch } from '../helpers.js';
|
|
3
3
|
|
|
4
4
|
const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
|
|
5
5
|
|
|
@@ -25,6 +25,7 @@ export function requestsCmd(wrap) {
|
|
|
25
25
|
|
|
26
26
|
async function enrichedShowHandler(argv, app) {
|
|
27
27
|
const number = argv.number;
|
|
28
|
+
assertSafeExactMatch(number);
|
|
28
29
|
const params = new URLSearchParams();
|
|
29
30
|
params.set('sysparm_query', `number=${number}`);
|
|
30
31
|
params.set('sysparm_display_value', 'all');
|
package/src/commands/setup.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
|
|
1
|
+
import { loginWizard } from './auth.js';
|
|
3
2
|
|
|
3
|
+
// `jsn setup` is kept as a hidden compatibility alias for `jsn auth login`.
|
|
4
|
+
// The wizard lives in auth.js (loginWizard) so there is exactly ONE code path
|
|
5
|
+
// for first-run onboarding — this command can never drift out of sync.
|
|
4
6
|
export function setupCmd(wrap) {
|
|
5
7
|
return {
|
|
6
8
|
command: 'setup',
|
|
7
|
-
describe: 'Interactive first-time setup',
|
|
9
|
+
describe: 'Interactive first-time setup (use "jsn auth login")',
|
|
10
|
+
hidden: true, // legacy alias for `jsn auth login` — works but hidden from help
|
|
8
11
|
builder: (yargs) => {
|
|
9
12
|
return yargs
|
|
10
13
|
.option('read-only', {
|
|
@@ -13,87 +16,10 @@ export function setupCmd(wrap) {
|
|
|
13
16
|
default: false,
|
|
14
17
|
});
|
|
15
18
|
},
|
|
16
|
-
handler: wrap(async (
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
console.log('Welcome to JSN - ServiceNow CLI');
|
|
21
|
-
console.log();
|
|
22
|
-
|
|
23
|
-
let instance = getEffectiveInstance(app.config);
|
|
24
|
-
if (!instance) {
|
|
25
|
-
instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
|
|
26
|
-
instance = normalizeInstanceURL(instance);
|
|
27
|
-
}
|
|
28
|
-
console.log(`Instance: ${instance}`);
|
|
29
|
-
|
|
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);
|
|
65
|
-
// Set as active profile so subsequent commands know which instance to use
|
|
66
|
-
app.config.activeProfile = profileName;
|
|
67
|
-
app.config.defaultProfile = profileName;
|
|
68
|
-
saveConfig(app.config);
|
|
69
|
-
|
|
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
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
rl.close();
|
|
96
|
-
app.ok({ setup: true, instance, profile: profileName }, { summary: 'Setup complete' });
|
|
19
|
+
handler: wrap(async (argv, app) => {
|
|
20
|
+
console.log('Note: "jsn setup" is now "jsn auth login". Running the same wizard...\n');
|
|
21
|
+
const result = await loginWizard(app, argv);
|
|
22
|
+
app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
|
|
97
23
|
}),
|
|
98
24
|
};
|
|
99
25
|
}
|
package/src/commands/skill.js
CHANGED
|
@@ -14,7 +14,7 @@ const SKILL_REPO_PATH = path.join(
|
|
|
14
14
|
'..', '..', 'skills', SKILL_NAME, 'SKILL.md'
|
|
15
15
|
);
|
|
16
16
|
|
|
17
|
-
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/
|
|
17
|
+
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/main/skills/servicenow/SKILL.md';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Resolve the user's real home directory, even when Hermes overrides $HOME.
|
|
@@ -39,7 +39,10 @@ function realHomeDir() {
|
|
|
39
39
|
* profile directory, falling back to ~/.hermes/skills/ (legacy symlink path).
|
|
40
40
|
*/
|
|
41
41
|
function hermesSkillDir() {
|
|
42
|
-
|
|
42
|
+
// JSN_HERMES_BASE_DIR lets tests point at a temp .hermes tree.
|
|
43
|
+
// realHomeDir() deliberately bypasses $HOME (Hermes overrides it to a
|
|
44
|
+
// sandbox dir), so tests can't control the base via HOME.
|
|
45
|
+
const base = process.env.JSN_HERMES_BASE_DIR || path.join(realHomeDir(), '.hermes');
|
|
43
46
|
const profileEnv = process.env.HERMES_PROFILE;
|
|
44
47
|
let profileName = profileEnv;
|
|
45
48
|
|
|
@@ -59,11 +62,6 @@ function hermesSkillDir() {
|
|
|
59
62
|
return path.join(base, 'skills', SKILL_NAME);
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
/** Resolve the installed SKILL.md path for Hermes. */
|
|
63
|
-
function hermesSkillPath() {
|
|
64
|
-
return path.join(hermesSkillDir(), 'SKILL.md');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
65
|
// Known agent skill directories (personal/user-level)
|
|
68
66
|
// Keyed by agent name for the --target flag
|
|
69
67
|
// Each value is the directory for the <name>/SKILL.md file
|
package/src/commands/tickets.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export function ticketsCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -60,6 +60,7 @@ export function ticketsCmd(wrap) {
|
|
|
60
60
|
aliases: ['get'],
|
|
61
61
|
describe: 'Show a ticket',
|
|
62
62
|
handler: wrap(async (argv, app) => {
|
|
63
|
+
assertSafeExactMatch(argv.number);
|
|
63
64
|
const params = new URLSearchParams();
|
|
64
65
|
params.set('sysparm_query', `number=${argv.number}`);
|
|
65
66
|
params.set('sysparm_limit', '1');
|
|
@@ -87,6 +88,7 @@ export function ticketsCmd(wrap) {
|
|
|
87
88
|
builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
|
|
88
89
|
handler: wrap(async (argv, app) => {
|
|
89
90
|
const recordData = JSON.parse(argv.data);
|
|
91
|
+
assertSafeExactMatch(argv.number);
|
|
90
92
|
const findParams = new URLSearchParams();
|
|
91
93
|
findParams.set('sysparm_query', `number=${argv.number}`);
|
|
92
94
|
findParams.set('sysparm_limit', '1');
|
|
@@ -103,6 +105,7 @@ export function ticketsCmd(wrap) {
|
|
|
103
105
|
command: 'delete <number>',
|
|
104
106
|
describe: 'Delete a ticket',
|
|
105
107
|
handler: wrap(async (argv, app) => {
|
|
108
|
+
assertSafeExactMatch(argv.number);
|
|
106
109
|
const findParams = new URLSearchParams();
|
|
107
110
|
findParams.set('sysparm_query', `number=${argv.number}`);
|
|
108
111
|
findParams.set('sysparm_limit', '1');
|
package/src/commands/users.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export function usersCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -57,6 +57,7 @@ export function usersCmd(wrap) {
|
|
|
57
57
|
describe: 'Show a user by user_name or sys_id',
|
|
58
58
|
handler: wrap(async (argv, app) => {
|
|
59
59
|
const id = argv.identifier;
|
|
60
|
+
assertSafeExactMatch(id);
|
|
60
61
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
61
62
|
const params = new URLSearchParams();
|
|
62
63
|
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
|
|
@@ -104,6 +105,7 @@ export function usersCmd(wrap) {
|
|
|
104
105
|
.option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
|
|
105
106
|
handler: wrap(async (argv, app) => {
|
|
106
107
|
const id = argv.identifier;
|
|
108
|
+
assertSafeExactMatch(id);
|
|
107
109
|
const recordData = JSON.parse(argv.data);
|
|
108
110
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
109
111
|
const params = new URLSearchParams();
|
|
@@ -130,6 +132,7 @@ export function usersCmd(wrap) {
|
|
|
130
132
|
describe: 'Delete a user by user_name or sys_id',
|
|
131
133
|
handler: wrap(async (argv, app) => {
|
|
132
134
|
const id = argv.identifier;
|
|
135
|
+
assertSafeExactMatch(id);
|
|
133
136
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
134
137
|
const params = new URLSearchParams();
|
|
135
138
|
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
|
package/src/config.js
CHANGED
|
@@ -168,3 +168,12 @@ export function setProfile(cfg, name, profile) {
|
|
|
168
168
|
cfg.profiles[name] = profile;
|
|
169
169
|
return saveConfig(cfg);
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
export function setActiveProfile(cfg, name) {
|
|
173
|
+
if (!cfg.profiles || !cfg.profiles[name]) {
|
|
174
|
+
throw new Error(`Profile not found: ${name}`);
|
|
175
|
+
}
|
|
176
|
+
cfg.defaultProfile = name;
|
|
177
|
+
cfg.activeProfile = name;
|
|
178
|
+
return saveConfig(cfg);
|
|
179
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -126,3 +126,29 @@ export function asError(err) {
|
|
|
126
126
|
export function isErrorCode(err, code) {
|
|
127
127
|
return err instanceof AppError && err.code === code;
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format a safety-guard failure (mutation guard middleware in cli.js).
|
|
132
|
+
* In JSON mode writes the documented error envelope to stdout so piped
|
|
133
|
+
* consumers (`jsn ... --json | jq`) get a structured error instead of a
|
|
134
|
+
* raw stderr message. Otherwise writes human text to stderr.
|
|
135
|
+
* Always exits non-zero via guardExit.
|
|
136
|
+
*/
|
|
137
|
+
export function guardError(argv, { code, message, hint = '' }) {
|
|
138
|
+
if (argv?.format === 'json') {
|
|
139
|
+
return {
|
|
140
|
+
stream: 'stdout',
|
|
141
|
+
text: JSON.stringify({ ok: false, error: message, code, hint }) + '\n',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
let text = `Error: ${message}\n`;
|
|
145
|
+
if (hint) text += `\n${hint}\n`;
|
|
146
|
+
return { stream: 'stderr', text };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function guardExit(argv, opts) {
|
|
150
|
+
const { stream, text } = guardError(argv, opts);
|
|
151
|
+
if (stream === 'stdout') process.stdout.write(text);
|
|
152
|
+
else process.stderr.write(text);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
package/src/help.js
CHANGED
|
@@ -107,10 +107,8 @@ const DATA_COMMANDS = [
|
|
|
107
107
|
];
|
|
108
108
|
|
|
109
109
|
const CONFIG_COMMANDS = [
|
|
110
|
-
{ name: '
|
|
111
|
-
{ name: '
|
|
112
|
-
{ name: 'profiles', alias: 'profile', desc: 'Instance profiles' },
|
|
113
|
-
{ name: 'updatesets', desc: 'Update sets (set, yolo, export, import)' },
|
|
110
|
+
{ name: 'auth', desc: 'Authentication (login, status, switch, refresh, logout, remove)' },
|
|
111
|
+
{ name: 'updatesets', desc: 'Update sets (set, create, export)' },
|
|
114
112
|
{ name: 'scopes', desc: 'Application scopes' },
|
|
115
113
|
];
|
|
116
114
|
|
package/src/helpers.js
CHANGED
|
@@ -1,8 +1,47 @@
|
|
|
1
1
|
// Shared helper utilities
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import readline from 'node:readline';
|
|
4
5
|
import { paginatedSearch } from './paginated-search.js';
|
|
5
6
|
import { isTTY, FormatAuto } from './output.js';
|
|
7
|
+
import { getActiveProfile } from './config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Confirm a destructive action before executing.
|
|
11
|
+
*
|
|
12
|
+
* Default posture is ASK: interactive terminals get a y/N prompt;
|
|
13
|
+
* non-interactive invocation (pipes, scripts, AI agents) is rejected
|
|
14
|
+
* unless --force is passed. A profile can opt out entirely with the
|
|
15
|
+
* per-instance `skip_confirmations` flag (set via `auth login
|
|
16
|
+
* --skip-confirmations`).
|
|
17
|
+
*
|
|
18
|
+
* @param {object} app — app instance (reads active profile from app.config)
|
|
19
|
+
* @param {object} argv — parsed args (checks argv.force)
|
|
20
|
+
* @param {string} question — e.g. `Delete incident INC0010001?`
|
|
21
|
+
* @returns {Promise<boolean>} true when the action may proceed
|
|
22
|
+
* @throws when confirmation is required but denied/unavailable
|
|
23
|
+
*/
|
|
24
|
+
export async function confirmDelete(app, argv, question) {
|
|
25
|
+
const profile = getActiveProfile(app.config);
|
|
26
|
+
if (profile?.skip_confirmations === true) return true;
|
|
27
|
+
if (argv.force) return true;
|
|
28
|
+
if (isTTY(process.stdout) && isTTY(process.stdin)) {
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
const answer = await new Promise((resolve) => {
|
|
31
|
+
rl.question(`${question} (y/N): `, resolve);
|
|
32
|
+
});
|
|
33
|
+
rl.close();
|
|
34
|
+
const response = answer.trim().toLowerCase();
|
|
35
|
+
if (response !== 'y' && response !== 'yes') {
|
|
36
|
+
throw new Error('Deletion cancelled');
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
6
45
|
|
|
7
46
|
export function getStringField(record, field) {
|
|
8
47
|
if (!record || typeof record !== 'object') return '';
|
|
@@ -74,6 +113,29 @@ export function resolveFieldsParam(columns) {
|
|
|
74
113
|
return ['sys_id', ...columns].join(',');
|
|
75
114
|
}
|
|
76
115
|
|
|
116
|
+
// ServiceNow encoded-query metacharacters. In sysparm_query, `^` is the
|
|
117
|
+
// AND-separator (^OR starts an OR clause), `= < > ~ !` are operators,
|
|
118
|
+
// and `,` is used by IN lists. An identifier containing any of these
|
|
119
|
+
// silently broadens an exact-match lookup — with sysparm_limit=1 the
|
|
120
|
+
// wrong record wins (issue #143 finding #5). Exact-match identifiers
|
|
121
|
+
// (numbers, sys_ids, names) never legitimately contain them.
|
|
122
|
+
const QUERY_METACHARS = /[\^=<>~!,]/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validate that an identifier is safe to use in an exact-match encoded
|
|
126
|
+
* query (field=value). Throws when the value contains characters that
|
|
127
|
+
* would change the query semantics.
|
|
128
|
+
* @param {string} value — identifier (number, sys_id, name)
|
|
129
|
+
*/
|
|
130
|
+
export function assertSafeExactMatch(value) {
|
|
131
|
+
if (value && QUERY_METACHARS.test(value)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Unsafe identifier for exact-match lookup: contains ServiceNow query characters (^ = < > ~ ! ,). ` +
|
|
134
|
+
`Refusing to build a query that could match more than the named record.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
77
139
|
/**
|
|
78
140
|
* Shared interactive list helper with search-as-you-type.
|
|
79
141
|
* All list commands that want an interactive TTY picker should use this.
|
|
@@ -89,12 +151,7 @@ export function resolveFieldsParam(columns) {
|
|
|
89
151
|
* @param {string} opts.labelField — field used to match selection (default: 'name')
|
|
90
152
|
* @returns {Promise<void>|null} null if no selection made or non-interactive
|
|
91
153
|
*/
|
|
92
|
-
function
|
|
93
|
-
const first = word.charAt(0).toLowerCase();
|
|
94
|
-
return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export async function interactiveList({ app, table, singular, columns, limit = 50, query = '', formatLabel, labelField = 'name' }) {
|
|
154
|
+
export async function interactiveList({ app, table, singular, columns, limit = 50, _query = '', formatLabel, labelField = 'name' }) {
|
|
98
155
|
app.requireInstance();
|
|
99
156
|
const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
|
|
100
157
|
if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin)) {
|
|
@@ -105,7 +162,7 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
105
162
|
const pickerFields = pickerColumns.join(',');
|
|
106
163
|
|
|
107
164
|
// Get total count
|
|
108
|
-
let totalCount
|
|
165
|
+
let totalCount;
|
|
109
166
|
try {
|
|
110
167
|
totalCount = await app.sdk.aggregateCount(table, '');
|
|
111
168
|
} catch {
|
|
@@ -114,7 +171,7 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
114
171
|
if (totalCount === 0) return null;
|
|
115
172
|
|
|
116
173
|
// Paginated source adapter: (term, offset, { signal }) => choices[]
|
|
117
|
-
async function serverSource(term, offset, { signal } = {}) {
|
|
174
|
+
async function serverSource(term, offset, { signal: _signal } = {}) {
|
|
118
175
|
try {
|
|
119
176
|
const params = new URLSearchParams();
|
|
120
177
|
params.set('sysparm_limit', String(limit));
|
package/src/mutations.js
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
// Used by the read-only profile middleware to block mutation commands.
|
|
3
3
|
// Each entry is a command path array matching argv._ tokens.
|
|
4
4
|
|
|
5
|
+
// Dev commands that expose create/update/delete via buildDevCmd.
|
|
6
|
+
// Each generates the root form (`jsn <name> create`) and the legacy dev form
|
|
7
|
+
// (`jsn dev <name> create`), so read-only profiles block both spellings.
|
|
8
|
+
const DEV_CRUD_COMMANDS = [
|
|
9
|
+
'actions', 'includes', 'rules', 'clientscripts', 'uiactions', 'uipolicies',
|
|
10
|
+
'tables', 'uipages', 'appmenu', 'acls', 'roles', 'properties', 'relationships',
|
|
11
|
+
'appmodules', 'listcontrols', 'views', 'privileges', 'uxscripts',
|
|
12
|
+
'catalogscripts', 'scriptactions', 'scheduledjobs', 'asyncrules', 'triggers',
|
|
13
|
+
'email', 'restmethods', 'restmessage', 'soapmessages', 'uimacros',
|
|
14
|
+
'cataloguipolicies', 'aliases', 'columns', 'flows', 'scrapi',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function devCrudPaths(name) {
|
|
18
|
+
return [
|
|
19
|
+
[name, 'create'],
|
|
20
|
+
[name, 'update'],
|
|
21
|
+
[name, 'delete'],
|
|
22
|
+
['dev', name, 'create'],
|
|
23
|
+
['dev', name, 'update'],
|
|
24
|
+
['dev', name, 'delete'],
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
export const MUTATION_COMMANDS = [
|
|
6
29
|
// Incidents
|
|
7
30
|
['incidents', 'create'],
|
|
@@ -37,18 +60,26 @@ export const MUTATION_COMMANDS = [
|
|
|
37
60
|
['groups', 'delete'],
|
|
38
61
|
// Catalog
|
|
39
62
|
['catalog', 'create-item'],
|
|
40
|
-
// Dev
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
// Dev CRUD commands (root + `jsn dev` forms)
|
|
64
|
+
...DEV_CRUD_COMMANDS.flatMap(devCrudPaths),
|
|
65
|
+
// Dev eval (no sub-action) — both spellings must be gated:
|
|
66
|
+
// `jsn eval` (root) and legacy `jsn dev eval`
|
|
67
|
+
['eval'],
|
|
68
|
+
['dev', 'eval'],
|
|
69
|
+
// Raw REST passthrough — can hit any endpoint with any method,
|
|
70
|
+
// so the whole command is a mutation surface on read-only profiles
|
|
71
|
+
['rest'],
|
|
72
|
+
['dev', 'rest'],
|
|
73
|
+
// Scopes: create + set (root + dev forms)
|
|
74
|
+
['scopes', 'create'],
|
|
75
|
+
['scopes', 'set'],
|
|
45
76
|
['dev', 'scopes', 'create'],
|
|
46
77
|
['dev', 'scopes', 'set'],
|
|
47
|
-
//
|
|
48
|
-
['
|
|
49
|
-
|
|
50
|
-
['dev', 'updatesets', 'set'],
|
|
78
|
+
// Update sets: create + set (root + dev forms)
|
|
79
|
+
['updatesets', 'create'],
|
|
80
|
+
['updatesets', 'set'],
|
|
51
81
|
['dev', 'updatesets', 'create'],
|
|
82
|
+
['dev', 'updatesets', 'set'],
|
|
52
83
|
];
|
|
53
84
|
|
|
54
85
|
/**
|
package/src/output.js
CHANGED
|
@@ -200,12 +200,15 @@ export class OutputWriter {
|
|
|
200
200
|
// Show lock icon for read-only profiles
|
|
201
201
|
const lockIcon = p.read_only ? ' 🔒' : '';
|
|
202
202
|
|
|
203
|
+
// Show confirmations-off badge for skip_confirmations profiles
|
|
204
|
+
const confirmBadge = p.skip_confirmations ? ' ⚡' : '';
|
|
205
|
+
|
|
203
206
|
if (p.name) {
|
|
204
|
-
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
207
|
+
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
205
208
|
} else if (p.username) {
|
|
206
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
209
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
207
210
|
} else {
|
|
208
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
211
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
214
|
}
|
package/src/paginated-search.js
CHANGED
package/src/records/inspect.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isHexString, getStringField } from '../helpers.js';
|
|
1
|
+
import { isHexString, getStringField, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export async function resolveIdentifier(app, table, identifier) {
|
|
4
4
|
// If it looks like a sys_id (32 hex chars), use it directly
|
|
@@ -7,6 +7,7 @@ export async function resolveIdentifier(app, table, identifier) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
// Otherwise, assume it's a record number — query the table
|
|
10
|
+
assertSafeExactMatch(identifier);
|
|
10
11
|
const params = new URLSearchParams();
|
|
11
12
|
params.set('sysparm_query', `number=${identifier}`);
|
|
12
13
|
params.set('sysparm_limit', '1');
|