@jacebenson/jsn 3.1.4 → 3.2.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/app.js +15 -1
- package/src/auth.js +36 -50
- package/src/cli.js +55 -13
- package/src/commands/dev/_simple.js +6 -6
- package/src/commands/dev/aliases.js +30 -0
- package/src/commands/dev/b4rules.js +1 -1
- package/src/commands/dev/columns.js +27 -0
- package/src/commands/dev/import.js +27 -0
- package/src/commands/dev/lists.js +93 -210
- package/src/commands/dev/uipolicies.js +28 -0
- package/src/commands/dev.js +1 -2
- package/src/commands/groups.js +23 -1
- package/src/commands/skill.js +203 -27
- package/src/commands/users.js +8 -1
- package/src/commands/version.js +2 -2
- package/src/help.js +0 -1
- package/src/output.js +3 -0
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { AuthManager } from './auth.js';
|
|
4
4
|
import { SDKClient } from './sdk.js';
|
|
5
5
|
import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled } from './output.js';
|
|
6
|
-
import { getEffectiveInstance } from './config.js';
|
|
6
|
+
import { getEffectiveInstance, normalizeInstanceURL } from './config.js';
|
|
7
7
|
import { extractProfileName } from './helpers.js';
|
|
8
8
|
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
9
9
|
import { errUsage, errAuth } from './errors.js';
|
|
@@ -55,6 +55,8 @@ export class App {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
getEffectiveInstance() {
|
|
58
|
+
// Command-line override (--instance, --profile) takes precedence
|
|
59
|
+
if (this._overrideInstance) return this._overrideInstance;
|
|
58
60
|
return getEffectiveInstance(this.config);
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -191,6 +193,18 @@ export class App {
|
|
|
191
193
|
throw errAuth('Not authenticated');
|
|
192
194
|
}
|
|
193
195
|
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Override the effective instance for this command run.
|
|
199
|
+
* Rebuilds the SDK client for the new instance.
|
|
200
|
+
*/
|
|
201
|
+
setEffectiveInstance(url) {
|
|
202
|
+
const normalizedUrl = normalizeInstanceURL(url);
|
|
203
|
+
if (normalizedUrl) {
|
|
204
|
+
this._overrideInstance = normalizedUrl;
|
|
205
|
+
this.sdk = new SDKClient(normalizedUrl, this.auth);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
194
208
|
}
|
|
195
209
|
|
|
196
210
|
function resolveFormat(fmt) {
|
package/src/auth.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
import readline from 'node:readline';
|
|
11
10
|
import { execSync } from 'node:child_process';
|
|
11
|
+
import { password as passwordPrompt } from '@inquirer/prompts';
|
|
12
12
|
import { globalConfigDir, normalizeInstanceURL } from './config.js';
|
|
13
13
|
import { errAuth } from './errors.js';
|
|
14
14
|
|
|
@@ -58,6 +58,24 @@ function removePKCEState(instance) {
|
|
|
58
58
|
const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
|
|
59
59
|
const REDIRECT_URI = '/sdk-oauth.do';
|
|
60
60
|
|
|
61
|
+
// ─── WSL detection ───
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect if running under Windows Subsystem for Linux.
|
|
65
|
+
* Reads /proc/version which contains "Microsoft" or "WSL" on WSL.
|
|
66
|
+
*/
|
|
67
|
+
let _cachedIsWSL = null;
|
|
68
|
+
async function isWSLEnv() {
|
|
69
|
+
if (_cachedIsWSL !== null) return _cachedIsWSL;
|
|
70
|
+
try {
|
|
71
|
+
const data = fs.readFileSync('/proc/version', 'utf-8');
|
|
72
|
+
_cachedIsWSL = /microsoft|wsl/i.test(data);
|
|
73
|
+
} catch {
|
|
74
|
+
_cachedIsWSL = false;
|
|
75
|
+
}
|
|
76
|
+
return _cachedIsWSL;
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
// ─── Basic auth from environment variables ───
|
|
62
80
|
// SN_USERNAME / SN_PASSWORD — global credentials
|
|
63
81
|
// SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
|
|
@@ -248,52 +266,7 @@ function deleteCredentials(instance, username) {
|
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
function askHidden(promptText) {
|
|
251
|
-
return
|
|
252
|
-
const rl = readline.createInterface({
|
|
253
|
-
input: process.stdin,
|
|
254
|
-
output: process.stdout,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const stdin = process.stdin;
|
|
258
|
-
const stdout = process.stdout;
|
|
259
|
-
|
|
260
|
-
if (!stdin.isTTY) {
|
|
261
|
-
rl.question(promptText, (answer) => {
|
|
262
|
-
rl.close();
|
|
263
|
-
resolve(answer.trim());
|
|
264
|
-
});
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
stdout.write(promptText);
|
|
269
|
-
|
|
270
|
-
stdin.setRawMode(true);
|
|
271
|
-
stdin.resume();
|
|
272
|
-
stdin.setEncoding('utf-8');
|
|
273
|
-
|
|
274
|
-
let input = '';
|
|
275
|
-
const onData = (key) => {
|
|
276
|
-
if (key === '\r' || key === '\n') {
|
|
277
|
-
stdin.removeListener('data', onData);
|
|
278
|
-
stdin.setRawMode(false);
|
|
279
|
-
stdin.pause();
|
|
280
|
-
stdout.write('\n');
|
|
281
|
-
rl.close();
|
|
282
|
-
resolve(input);
|
|
283
|
-
} else if (key === '\u0003') {
|
|
284
|
-
process.exit();
|
|
285
|
-
} else if (key === '\u007f') {
|
|
286
|
-
if (input.length > 0) {
|
|
287
|
-
input = input.slice(0, -1);
|
|
288
|
-
stdout.write('\b \b');
|
|
289
|
-
}
|
|
290
|
-
} else {
|
|
291
|
-
input += key;
|
|
292
|
-
stdout.write('*');
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
|
-
stdin.on('data', onData);
|
|
296
|
-
});
|
|
269
|
+
return passwordPrompt({ message: promptText });
|
|
297
270
|
}
|
|
298
271
|
|
|
299
272
|
export class AuthManager {
|
|
@@ -431,10 +404,23 @@ export class AuthManager {
|
|
|
431
404
|
args = [authURL];
|
|
432
405
|
} else if (platform === 'win32') {
|
|
433
406
|
cmd = 'cmd';
|
|
434
|
-
|
|
407
|
+
// Windows: cmd.exe interprets & as a command separator, which truncates
|
|
408
|
+
// the URL after the first parameter. Passing an empty string as the first
|
|
409
|
+
// arg to `start` serves as the window title, and spawn auto-quotes the
|
|
410
|
+
// URL arg, protecting & and other special chars from cmd.exe's parser.
|
|
411
|
+
args = ['/c', 'start', '', authURL];
|
|
435
412
|
} else {
|
|
436
|
-
|
|
437
|
-
|
|
413
|
+
// WSL detection: check if we're running under Windows Subsystem for Linux
|
|
414
|
+
// On WSL, xdg-open delegates to cmd.exe /C start <url>, which truncates
|
|
415
|
+
// on &. We call cmd.exe directly with proper quoting instead.
|
|
416
|
+
const isWSL = platform === 'linux' && (await isWSLEnv());
|
|
417
|
+
if (isWSL) {
|
|
418
|
+
cmd = 'cmd.exe';
|
|
419
|
+
args = ['/c', 'start', '', authURL];
|
|
420
|
+
} else {
|
|
421
|
+
cmd = 'xdg-open';
|
|
422
|
+
args = [authURL];
|
|
423
|
+
}
|
|
438
424
|
}
|
|
439
425
|
const child = open(cmd, args, { detached: true, stdio: 'ignore' });
|
|
440
426
|
child.on('error', () => {
|
package/src/cli.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import yargs from 'yargs';
|
|
4
4
|
import { hideBin } from 'yargs/helpers';
|
|
5
5
|
import process from 'node:process';
|
|
6
|
-
import
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { loadConfig, getEffectiveInstance, globalConfigDir } from './config.js';
|
|
7
9
|
import { App } from './app.js';
|
|
8
10
|
import { renderHelp } from './help.js';
|
|
9
11
|
import { isMutationCommand } from './mutations.js';
|
|
@@ -25,7 +27,8 @@ import { groupRolesCmd } from './commands/grouproles.js';
|
|
|
25
27
|
import { ticketsCmd } from './commands/tickets.js';
|
|
26
28
|
import { versionCmd } from './commands/version.js';
|
|
27
29
|
import { devCmd } from './commands/dev.js';
|
|
28
|
-
import { skillCmd
|
|
30
|
+
import { skillCmd } from './commands/skill.js';
|
|
31
|
+
import { getVersion, checkLatest } from './commands/version.js';
|
|
29
32
|
|
|
30
33
|
// Dev subcommands promoted to root level for progressive disclosure
|
|
31
34
|
import {
|
|
@@ -35,8 +38,9 @@ import {
|
|
|
35
38
|
spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
|
|
36
39
|
aclsCmd, rolesCmd, propertiesCmd,
|
|
37
40
|
relationshipsCmd, appmodulesCmd, listcontrolsCmd, viewsCmd,
|
|
38
|
-
privilegesCmd,
|
|
41
|
+
privilegesCmd, uxscriptsCmd, aliasesCmd,
|
|
39
42
|
catalogscriptsCmd, cataloguipoliciesCmd,
|
|
43
|
+
uipoliciesCmd,
|
|
40
44
|
scriptactionsCmd, scheduledjobsCmd, asyncrulesCmd, triggersCmd,
|
|
41
45
|
decisiontablesCmd, assignmentsCmd,
|
|
42
46
|
emailCmd,
|
|
@@ -152,8 +156,23 @@ export function buildCLI() {
|
|
|
152
156
|
if (argv.format) {
|
|
153
157
|
app.output.setFormat(argv.format);
|
|
154
158
|
}
|
|
159
|
+
// --profile: resolve profile name to its instance URL
|
|
160
|
+
// --instance wins if both are specified (explicit URL > profile reference)
|
|
161
|
+
if (argv.profile) {
|
|
162
|
+
const profile = (cfg.profiles || {})[argv.profile];
|
|
163
|
+
if (!profile) {
|
|
164
|
+
process.stderr.write(`Error: Unknown profile "${argv.profile}"\n`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
const url = profile.instance_url;
|
|
168
|
+
app._overrideInstance = url;
|
|
169
|
+
argv._overrideInstance = url;
|
|
170
|
+
// Temporarily switch active profile so auth picks up the right user
|
|
171
|
+
cfg.activeProfile = argv.profile;
|
|
172
|
+
}
|
|
155
173
|
if (argv.instance) {
|
|
156
174
|
app._overrideInstance = argv.instance;
|
|
175
|
+
argv._overrideInstance = argv.instance;
|
|
157
176
|
}
|
|
158
177
|
},
|
|
159
178
|
// Guard mutation commands when no instance configured
|
|
@@ -165,6 +184,7 @@ export function buildCLI() {
|
|
|
165
184
|
// Check for --instance override
|
|
166
185
|
if (argv.instance) {
|
|
167
186
|
app._overrideInstance = argv.instance;
|
|
187
|
+
argv._overrideInstance = argv.instance;
|
|
168
188
|
}
|
|
169
189
|
// Determine if this is a mutation
|
|
170
190
|
const subcommand = (argv._[1] || '').toString();
|
|
@@ -182,18 +202,40 @@ export function buildCLI() {
|
|
|
182
202
|
|
|
183
203
|
const cmd = (argv._[0] || '').toString();
|
|
184
204
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
||
|
|
205
|
+
// Daily npm version check (fire-and-forget, non-blocking)
|
|
206
|
+
// Checks npm for newer jsn releases, at most once per 24 hours.
|
|
207
|
+
const skipNpmCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
|
|
208
|
+
|| process.env.JSN_NO_VERSION_CHECK === '1'
|
|
189
209
|
|| argv.json
|
|
190
210
|
|| argv.quiet;
|
|
191
|
-
if (!
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
if (!skipNpmCheck) {
|
|
212
|
+
const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
|
|
213
|
+
let shouldCheck = true;
|
|
214
|
+
try {
|
|
215
|
+
const mtime = fs.statSync(cacheFile).mtimeMs;
|
|
216
|
+
const elapsed = Date.now() - mtime;
|
|
217
|
+
// Only check if >= 24 hours since last check
|
|
218
|
+
if (elapsed < 86400000) shouldCheck = false;
|
|
219
|
+
} catch {
|
|
220
|
+
// File doesn't exist — check
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (shouldCheck) {
|
|
224
|
+
const version = getVersion();
|
|
225
|
+
checkLatest().then(latest => {
|
|
226
|
+
if (latest && version !== latest) {
|
|
227
|
+
process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
|
|
228
|
+
}
|
|
229
|
+
}).catch(() => {});
|
|
230
|
+
|
|
231
|
+
// Stamp the cache file (write async, don't block)
|
|
232
|
+
try {
|
|
233
|
+
fs.mkdirSync(globalConfigDir(), { recursive: true });
|
|
234
|
+
fs.writeFileSync(cacheFile, String(Date.now()), 'utf-8');
|
|
235
|
+
} catch {
|
|
236
|
+
// Non-fatal
|
|
195
237
|
}
|
|
196
|
-
}
|
|
238
|
+
}
|
|
197
239
|
}
|
|
198
240
|
|
|
199
241
|
// Print context header for interactive terminals
|
|
@@ -235,7 +277,6 @@ export function buildCLI() {
|
|
|
235
277
|
.command(b4rulesCmd(wrap))
|
|
236
278
|
.command(rolesCmd(wrap))
|
|
237
279
|
.command(privilegesCmd(wrap))
|
|
238
|
-
.command(securitytypesCmd(wrap))
|
|
239
280
|
.command(aliasesCmd(wrap))
|
|
240
281
|
.command(groupsCmd(wrap))
|
|
241
282
|
.command(groupMembersCmd(wrap))
|
|
@@ -249,6 +290,7 @@ export function buildCLI() {
|
|
|
249
290
|
.command(uiActionsCmd(wrap))
|
|
250
291
|
.command(viewsCmd(wrap))
|
|
251
292
|
.command(catalogscriptsCmd(wrap))
|
|
293
|
+
.command(uipoliciesCmd(wrap))
|
|
252
294
|
.command(cataloguipoliciesCmd(wrap))
|
|
253
295
|
// USER EXPERIENCE — Core UI
|
|
254
296
|
.command(uiPagesCmd(wrap))
|
|
@@ -73,10 +73,10 @@ export const tablesCmd = (wrap) => buildDevCmd('tables', 'sys_db_object', ['tabl
|
|
|
73
73
|
},
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
export
|
|
76
|
+
export { columnsCmd } from './columns.js';
|
|
77
77
|
|
|
78
78
|
// Read-only commands (Go only has list/show)
|
|
79
|
-
export
|
|
79
|
+
export { importCmd } from './import.js';
|
|
80
80
|
export const spPagesCmd = (wrap) => buildDevCmd('sppages', 'sp_page', ['sp-pages', 'pages'], ['id', 'title', 'sys_scope'], wrap, { singular: 'Service Portal page', readOnly: true });
|
|
81
81
|
export const spWidgetsCmd = (wrap) => buildDevCmd('spwidgets', 'sp_widget', ['sp-widget', 'widgets'], ['id', 'name', 'sys_scope'], wrap, { singular: 'Service Portal widget', readOnly: true });
|
|
82
82
|
export const uiPagesCmd = (wrap) => buildDevCmd('uipages', 'sys_ui_page', ['ui-page', 'pages'], ['name', 'sys_scope'], wrap, { singular: 'UI page', scopeValidation: true });
|
|
@@ -92,10 +92,9 @@ export const relationshipsCmd = (wrap) => buildDevCmd('relationships', 'sys_rela
|
|
|
92
92
|
export const appmodulesCmd = (wrap) => buildDevCmd('appmodules', 'sys_app_module', ['appmodule', 'am'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application module', scopeValidation: true });
|
|
93
93
|
export const listcontrolsCmd = (wrap) => buildDevCmd('listcontrols', 'sys_ui_list_control', ['listcontrol', 'lc'], ['name', 'active', 'sys_scope'], wrap, { singular: 'list control', scopeValidation: true });
|
|
94
94
|
export const viewsCmd = (wrap) => buildDevCmd('views', 'sys_ui_view', ['view', 'vw'], ['name', 'title', 'sys_scope'], wrap, { singular: 'view', scopeValidation: true });
|
|
95
|
-
export const privilegesCmd = (wrap) => buildDevCmd('privileges', 'sys_scope_privilege', ['privilege', 'priv'], ['
|
|
96
|
-
export const securitytypesCmd = (wrap) => buildDevCmd('securitytypes', 'sys_security_type', ['securitytype', 'st'], ['name', 'active', 'sys_scope'], wrap, { singular: 'security type', scopeValidation: true });
|
|
95
|
+
export const privilegesCmd = (wrap) => buildDevCmd('privileges', 'sys_scope_privilege', ['privilege', 'priv'], ['source_scope', 'target_scope', 'target_name', 'operation', 'status'], wrap, { singular: 'privilege', scopeValidation: true });
|
|
97
96
|
export const uxscriptsCmd = (wrap) => buildDevCmd('uxscripts', 'sys_ux_lib_source_script', ['uxscript', 'ux'], ['name', 'active', 'sys_scope'], wrap, { singular: 'UX script', scopeValidation: true });
|
|
98
|
-
export
|
|
97
|
+
export { aliasesCmd } from './aliases.js';
|
|
99
98
|
export const catalogscriptsCmd = (wrap) => buildDevCmd('catalogscripts', 'catalog_script_client', ['catalogscript', 'cs'], ['name', 'table', 'active', 'sys_scope'], wrap, { singular: 'catalog script', scopeValidation: true });
|
|
100
99
|
|
|
101
100
|
// ── Automation: Async ──
|
|
@@ -122,4 +121,5 @@ export const uimacrosCmd = (wrap) => buildDevCmd('uimacros', 'sys_ui_macro', ['u
|
|
|
122
121
|
// ── UX: Workspaces ──
|
|
123
122
|
export const uxlistsCmd = (wrap) => buildDevCmd('uxlists', 'sys_ux_list', ['uxlist'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace list', scopeValidation: true, readOnly: true });
|
|
124
123
|
export const uxapplicabilityCmd = (wrap) => buildDevCmd('uxapplicability', 'sys_ux_applicability_m2m', ['uxapp'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace applicability', scopeValidation: true, readOnly: true });
|
|
125
|
-
export
|
|
124
|
+
export { uipoliciesCmd } from './uipolicies.js';
|
|
125
|
+
export const cataloguipoliciesCmd = (wrap) => buildDevCmd('cataloguipolicies', 'catalog_ui_policy', ['cataloguipolicy', 'cup'], ['short_description', 'catalog_item', 'variable_set', 'active', 'sys_scope'], wrap, { singular: 'catalog UI policy', scopeValidation: true });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { buildDevCmd } from './_generic.js';
|
|
2
|
+
import { getStringField } from '../../helpers.js';
|
|
3
|
+
|
|
4
|
+
export const aliasesCmd = (wrap) => buildDevCmd('aliases', 'sys_alias', ['alias', 'als'],
|
|
5
|
+
['name', 'table', 'sys_scope'], wrap, {
|
|
6
|
+
singular: 'alias',
|
|
7
|
+
scopeValidation: true,
|
|
8
|
+
onShow: async (record, app) => {
|
|
9
|
+
const sysID = getStringField(record, 'sys_id') || '';
|
|
10
|
+
if (!sysID) return;
|
|
11
|
+
|
|
12
|
+
const mp = new URLSearchParams();
|
|
13
|
+
mp.set('sysparm_query', `connection_alias=${sysID}^ORDERBYname`);
|
|
14
|
+
mp.set('sysparm_limit', '100');
|
|
15
|
+
mp.set('sysparm_display_value', 'all');
|
|
16
|
+
mp.set('sysparm_fields', 'name,active,credential,sys_id');
|
|
17
|
+
const conns = await app.sdk.list('sys_connection', mp);
|
|
18
|
+
|
|
19
|
+
if (conns.length > 0) {
|
|
20
|
+
console.log(` Connections (${conns.length}):`);
|
|
21
|
+
for (const c of conns) {
|
|
22
|
+
const name = getStringField(c, 'name') || '';
|
|
23
|
+
const cred = getStringField(c, 'credential') || '';
|
|
24
|
+
const cid = getStringField(c, 'sys_id') || '';
|
|
25
|
+
console.log(` ${name}${cred ? ` → ${cred}` : ''}`);
|
|
26
|
+
console.log(` → jsn records get --table sys_connection --sys-id ${cid}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -2,4 +2,4 @@ import { buildDevCmd } from './_generic.js';
|
|
|
2
2
|
|
|
3
3
|
// Before-query business rules — applies filters to GlideRecord queries before they execute.
|
|
4
4
|
// These are sys_script records with order < 100.
|
|
5
|
-
export const b4rulesCmd = (wrap) => buildDevCmd('b4rules', 'sys_script', ['b4rule'], ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, { singular: 'before-query business rule', scopeValidation: true, extraQuery: '
|
|
5
|
+
export const b4rulesCmd = (wrap) => buildDevCmd('b4rules', 'sys_script', ['b4rule'], ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, { singular: 'before-query business rule', scopeValidation: true, extraQuery: 'when=before' });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildDevCmd } from './_generic.js';
|
|
2
|
+
|
|
3
|
+
export const columnsCmd = (wrap) => {
|
|
4
|
+
const base = buildDevCmd('columns', 'sys_dictionary', ['column', 'col'],
|
|
5
|
+
['element', 'column_label', 'internal_type', 'mandatory', 'max_length', 'active'],
|
|
6
|
+
wrap, { singular: 'column', scopeValidation: true });
|
|
7
|
+
|
|
8
|
+
// Add --table filter
|
|
9
|
+
const origBuilder = base.builder;
|
|
10
|
+
base.builder = (yargs) => {
|
|
11
|
+
const built = origBuilder(yargs);
|
|
12
|
+
// Patch the 'list' subcommand builder to add --table
|
|
13
|
+
const listCmd = built._commands?.find(c => c.name === 'list' || c._aliases?.includes('ls'));
|
|
14
|
+
if (listCmd) {
|
|
15
|
+
const origListBuilder = listCmd.builder;
|
|
16
|
+
listCmd.builder = (y) => (origListBuilder ? origListBuilder(y).option('table', { type: 'string', describe: 'Filter by table (e.g. incident)' }) : y.option('table', { type: 'string' }));
|
|
17
|
+
const origHandler = listCmd.handler;
|
|
18
|
+
listCmd.handler = async (argv) => {
|
|
19
|
+
if (argv.table) argv.query = argv.query ? `${argv.query}^name=${argv.table}` : `name=${argv.table}`;
|
|
20
|
+
return origHandler(argv);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return built;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return base;
|
|
27
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildDevCmd } from './_generic.js';
|
|
2
|
+
import { getStringField } from '../../helpers.js';
|
|
3
|
+
|
|
4
|
+
export const importCmd = (wrap) => buildDevCmd('import', 'sys_import_set', ['imports', 'imp'],
|
|
5
|
+
['name', 'sys_target_table', 'state', 'sys_created_on'], wrap, {
|
|
6
|
+
singular: 'import set',
|
|
7
|
+
readOnly: true,
|
|
8
|
+
formatLabel: (r) => {
|
|
9
|
+
const name = getStringField(r, 'name') || '';
|
|
10
|
+
const table = getStringField(r, 'sys_target_table') || '';
|
|
11
|
+
return table ? `${name} [${table}]` : name;
|
|
12
|
+
},
|
|
13
|
+
onShow: async (record, app) => {
|
|
14
|
+
const sysID = getStringField(record, 'sys_id') || '';
|
|
15
|
+
if (!sysID) return;
|
|
16
|
+
try {
|
|
17
|
+
const rp = new URLSearchParams();
|
|
18
|
+
rp.set('sysparm_query', `sys_import_set=${sysID}`);
|
|
19
|
+
rp.set('sysparm_limit', '0'); rp.set('sysparm_fields', 'sys_id');
|
|
20
|
+
const rows = await app.sdk.list('sys_import_set_row', rp);
|
|
21
|
+
console.log(` Import rows: ${rows.length}`);
|
|
22
|
+
console.log(` → jsn records list --table sys_import_set_row --query "sys_import_set=${sysID}" --limit 200`);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.log(` Import rows: (unavailable)`);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -1,99 +1,68 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { getStringField } from '../../helpers.js';
|
|
3
|
-
|
|
4
|
-
// ─── Helpers ───
|
|
5
|
-
|
|
6
|
-
function getDisplayValue(record, key) {
|
|
7
|
-
if (!record || typeof record !== 'object') return '';
|
|
8
|
-
const val = record[key];
|
|
9
|
-
if (val == null) return '';
|
|
10
|
-
if (typeof val === 'string') return val;
|
|
11
|
-
if (typeof val === 'object') {
|
|
12
|
-
if (val.display_value != null && val.display_value !== '') return String(val.display_value);
|
|
13
|
-
if (val.value != null) return String(val.value);
|
|
14
|
-
}
|
|
15
|
-
return String(val);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function getIntValue(record, key) {
|
|
19
|
-
if (!record || typeof record !== 'object') return 0;
|
|
20
|
-
const val = record[key];
|
|
21
|
-
if (val == null) return 0;
|
|
22
|
-
if (typeof val === 'number') return Math.floor(val);
|
|
23
|
-
if (typeof val === 'string') {
|
|
24
|
-
const n = parseInt(val, 10);
|
|
25
|
-
return isNaN(n) ? 0 : n;
|
|
26
|
-
}
|
|
27
|
-
if (typeof val === 'object' && val.value != null) {
|
|
28
|
-
const n = parseInt(val.value, 10);
|
|
29
|
-
return isNaN(n) ? 0 : n;
|
|
30
|
-
}
|
|
31
|
-
return 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ─── Commands ───
|
|
1
|
+
import { getStringField, interactiveList } from '../../helpers.js';
|
|
35
2
|
|
|
36
3
|
export function listsCmd(wrap) {
|
|
37
4
|
return {
|
|
38
5
|
command: 'lists [subcommand]',
|
|
39
6
|
aliases: ['list-layout', 'l'],
|
|
40
|
-
describe: '
|
|
7
|
+
describe: 'List views for a table',
|
|
41
8
|
builder: (yargs) => {
|
|
42
9
|
return yargs
|
|
43
10
|
.command({
|
|
44
|
-
command: 'list
|
|
11
|
+
command: 'list [table]',
|
|
45
12
|
aliases: ['ls'],
|
|
46
|
-
describe: 'List
|
|
13
|
+
describe: 'List views for a table (or pick from all tables)',
|
|
47
14
|
builder: (y) => y
|
|
48
|
-
.positional('table', {
|
|
49
|
-
|
|
50
|
-
type: 'string',
|
|
51
|
-
})
|
|
52
|
-
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
15
|
+
.positional('table', { type: 'string', describe: 'Table name (e.g. incident)' })
|
|
16
|
+
.option('limit', { alias: 'l', type: 'number', default: 50 }),
|
|
53
17
|
handler: wrap(async (argv, app) => {
|
|
54
18
|
app.requireInstance();
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
for (const record of records) {
|
|
70
|
-
const view = getDisplayValue(record, 'view');
|
|
71
|
-
if (view && !viewMap.has(view)) {
|
|
72
|
-
viewMap.set(view, true);
|
|
73
|
-
}
|
|
19
|
+
let table = argv.table;
|
|
20
|
+
let viewSysID = null;
|
|
21
|
+
|
|
22
|
+
// If no table, pick one interactively
|
|
23
|
+
if (!table) {
|
|
24
|
+
const tpicked = await interactiveList({
|
|
25
|
+
app, table: 'sys_ui_list', singular: 'table',
|
|
26
|
+
columns: ['name'],
|
|
27
|
+
limit: argv.limit,
|
|
28
|
+
labelField: 'name',
|
|
29
|
+
groupBy: 'name',
|
|
30
|
+
});
|
|
31
|
+
if (!tpicked) return;
|
|
32
|
+
table = getStringField(tpicked, 'name');
|
|
74
33
|
}
|
|
75
34
|
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
35
|
+
// Show list views for table
|
|
36
|
+
const picked = await interactiveList({
|
|
37
|
+
app, table: 'sys_ui_list', singular: 'list',
|
|
38
|
+
columns: ['name', 'view'],
|
|
39
|
+
limit: argv.limit,
|
|
40
|
+
query: table ? `name=${table}` : '',
|
|
41
|
+
labelField: 'view',
|
|
42
|
+
formatLabel: (r) => `${getStringField(r, 'view')}`,
|
|
43
|
+
});
|
|
80
44
|
|
|
81
|
-
|
|
45
|
+
if (picked) {
|
|
46
|
+
const sysID = getStringField(picked, 'sys_id') || '';
|
|
47
|
+
viewSysID = getStringField(picked, 'view') || '';
|
|
48
|
+
await showList(app, table, viewSysID, sysID);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
82
51
|
|
|
52
|
+
// Fallback non-TTY
|
|
53
|
+
const p = new URLSearchParams();
|
|
54
|
+
p.set('sysparm_limit', String(argv.limit));
|
|
55
|
+
p.set('sysparm_display_value', 'all');
|
|
56
|
+
p.set('sysparm_fields', 'name,view');
|
|
57
|
+
p.set('sysparm_query', table ? `name=${table}^ORDERBYview` : 'ORDERBYview');
|
|
58
|
+
const records = await app.sdk.list('sys_ui_list', p);
|
|
83
59
|
app.ok({
|
|
84
|
-
table,
|
|
85
|
-
count:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
instance_url: app.getEffectiveInstance(),
|
|
91
|
-
}, {
|
|
92
|
-
summary: `${views.length} list views for ${table}`,
|
|
93
|
-
breadcrumbs: [
|
|
94
|
-
{ action: 'show', cmd: `jsn lists show ${table} --view "Default view"`, description: 'Show Default view columns' },
|
|
95
|
-
],
|
|
96
|
-
});
|
|
60
|
+
table: 'sys_ui_list',
|
|
61
|
+
count: records.length,
|
|
62
|
+
columns: ['name', 'view'],
|
|
63
|
+
records: records.map(r => ({ name: getStringField(r, 'name'), view: getStringField(r, 'view') })),
|
|
64
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
65
|
+
}, { summary: `${records.length} list view(s)${table ? ` for ${table}` : ''}` });
|
|
97
66
|
}),
|
|
98
67
|
})
|
|
99
68
|
.command({
|
|
@@ -101,147 +70,61 @@ export function listsCmd(wrap) {
|
|
|
101
70
|
aliases: ['get'],
|
|
102
71
|
describe: 'Show list layout',
|
|
103
72
|
builder: (y) => y
|
|
104
|
-
.option('view', { type: 'string', default: 'Default view'
|
|
73
|
+
.option('view', { type: 'string', default: 'Default view' }),
|
|
105
74
|
handler: wrap(async (argv, app) => {
|
|
106
75
|
app.requireInstance();
|
|
107
76
|
const table = argv.table;
|
|
108
|
-
const viewName = argv.view;
|
|
109
77
|
|
|
110
|
-
//
|
|
78
|
+
// Resolve view sys_id
|
|
111
79
|
let viewSysID = '';
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
viewParams.set('sysparm_query', `title=${viewName}`);
|
|
127
|
-
try {
|
|
128
|
-
const viewRecords = await app.sdk.list('sys_ui_view', viewParams);
|
|
129
|
-
if (viewRecords.length > 0) {
|
|
130
|
-
viewSysID = getStringField(viewRecords[0], 'sys_id');
|
|
131
|
-
}
|
|
132
|
-
} catch {
|
|
133
|
-
// ignore
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (!viewSysID) {
|
|
138
|
-
viewSysID = viewName;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Fetch list layouts
|
|
142
|
-
const params = new URLSearchParams();
|
|
143
|
-
params.set('sysparm_limit', '10');
|
|
144
|
-
params.set('sysparm_fields', 'sys_id,name,view,parent,active,sys_created_on,sys_updated_on');
|
|
145
|
-
params.set('sysparm_display_value', 'all');
|
|
146
|
-
|
|
147
|
-
const parts = [];
|
|
148
|
-
if (table) parts.push(`name=${table}`);
|
|
149
|
-
if (viewSysID) parts.push(`view=${viewSysID}`);
|
|
150
|
-
const sysparmQuery = parts.length > 0 ? `${parts.join('^')}^ORDERBYsys_created_on` : 'ORDERBYsys_created_on';
|
|
151
|
-
params.set('sysparm_query', sysparmQuery);
|
|
152
|
-
|
|
153
|
-
const records = await app.sdk.list('sys_ui_list', params);
|
|
154
|
-
if (records.length === 0) {
|
|
155
|
-
throw new Error(`no list layout found for ${table} with view "${viewName}"`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const mainLayout = records[0];
|
|
159
|
-
const layoutSysID = getStringField(mainLayout, 'sys_id');
|
|
160
|
-
|
|
161
|
-
// Fetch elements (columns) for the list
|
|
162
|
-
const elemParams = new URLSearchParams();
|
|
163
|
-
elemParams.set('sysparm_limit', '100');
|
|
164
|
-
elemParams.set('sysparm_fields', 'sys_id,list_id,element,position,type');
|
|
165
|
-
elemParams.set('sysparm_display_value', 'all');
|
|
166
|
-
elemParams.set('sysparm_query', `list_id=${layoutSysID}^ORDERBYposition`);
|
|
167
|
-
|
|
168
|
-
const elemRecords = await app.sdk.list('sys_ui_list_element', elemParams);
|
|
169
|
-
|
|
170
|
-
const elements = elemRecords.map(record => ({
|
|
171
|
-
sys_id: getStringField(record, 'sys_id'),
|
|
172
|
-
listID: getStringField(record, 'list_id'),
|
|
173
|
-
element: getDisplayValue(record, 'element'),
|
|
174
|
-
position: getIntValue(record, 'position'),
|
|
175
|
-
type: getDisplayValue(record, 'type'),
|
|
176
|
-
}));
|
|
177
|
-
|
|
178
|
-
elements.sort((a, b) => a.position - b.position);
|
|
179
|
-
|
|
180
|
-
// Build formatted output
|
|
181
|
-
const lines = [];
|
|
182
|
-
lines.push('');
|
|
183
|
-
lines.push(chalk.bold(chalk.hex('#e8a217')(`${table} (${viewName})`)));
|
|
184
|
-
lines.push('');
|
|
185
|
-
|
|
186
|
-
lines.push(chalk.bold(chalk.hex('#666666')('─ List Columns ─')));
|
|
187
|
-
if (elements.length === 0) {
|
|
188
|
-
lines.push(chalk.hex('#888888')(' (no columns defined)'));
|
|
189
|
-
} else {
|
|
190
|
-
for (let i = 0; i < elements.length; i++) {
|
|
191
|
-
const elem = elements[i];
|
|
192
|
-
lines.push(` ${chalk.hex('#666666')(`${String(i + 1).padStart(2)}.`)} ${chalk.hex('#cccccc')(elem.element)}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
lines.push('');
|
|
196
|
-
|
|
197
|
-
lines.push('─────');
|
|
198
|
-
lines.push('');
|
|
199
|
-
lines.push(chalk.bold(chalk.hex('#e8a217')('Hints:')));
|
|
200
|
-
lines.push(` ${`jsn lists list ${table}`.padEnd(50)} ${chalk.hex('#888888')('List all views')}`);
|
|
201
|
-
lines.push(` ${`jsn forms show ${table} --view "${viewName}"`.padEnd(50)} ${chalk.hex('#888888')('Show form layout')}`);
|
|
202
|
-
lines.push('');
|
|
203
|
-
|
|
204
|
-
const formatted = lines.join('\n');
|
|
205
|
-
|
|
206
|
-
// Build structured data for JSON/quiet mode
|
|
207
|
-
const columnsData = elements.map(elem => {
|
|
208
|
-
const col = {
|
|
209
|
-
element: elem.element,
|
|
210
|
-
position: elem.position,
|
|
211
|
-
};
|
|
212
|
-
if (elem.type) {
|
|
213
|
-
col.type = elem.type;
|
|
214
|
-
}
|
|
215
|
-
return col;
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
app.ok({
|
|
219
|
-
table,
|
|
220
|
-
view: viewName,
|
|
221
|
-
columns: columnsData,
|
|
222
|
-
_formatted: formatted,
|
|
223
|
-
_context: {
|
|
224
|
-
instance_url: app.getEffectiveInstance(),
|
|
225
|
-
table: 'sys_ui_list',
|
|
226
|
-
},
|
|
227
|
-
}, {
|
|
228
|
-
summary: `List: ${table} (${viewName}) - ${elements.length} columns`,
|
|
229
|
-
breadcrumbs: [
|
|
230
|
-
{ action: 'list', cmd: `jsn lists list ${table}`, description: 'List all views' },
|
|
231
|
-
{ action: 'form', cmd: `jsn forms show ${table} --view "${viewName}"`, description: 'Show form layout' },
|
|
232
|
-
],
|
|
233
|
-
});
|
|
80
|
+
const vp = new URLSearchParams();
|
|
81
|
+
vp.set('sysparm_limit', '1'); vp.set('sysparm_fields', 'sys_id');
|
|
82
|
+
vp.set('sysparm_query', `name=${argv.view}^name=${table}`);
|
|
83
|
+
const vr = await app.sdk.list('sys_ui_view', vp);
|
|
84
|
+
viewSysID = vr.length > 0 ? getStringField(vr[0], 'sys_id') : '';
|
|
85
|
+
|
|
86
|
+
// Find list layout
|
|
87
|
+
const lp = new URLSearchParams();
|
|
88
|
+
lp.set('sysparm_limit', '1'); lp.set('sysparm_display_value', 'all');
|
|
89
|
+
lp.set('sysparm_fields', 'sys_id,name,view');
|
|
90
|
+
lp.set('sysparm_query', `name=${table}^view=${viewSysID || argv.view}`);
|
|
91
|
+
const layouts = await app.sdk.list('sys_ui_list', lp);
|
|
92
|
+
if (layouts.length === 0) throw new Error(`No list layout for ${table} view "${argv.view}"`);
|
|
93
|
+
await showList(app, table, getStringField(layouts[0], 'view') || argv.view, getStringField(layouts[0], 'sys_id'));
|
|
234
94
|
}),
|
|
235
95
|
});
|
|
236
96
|
},
|
|
237
97
|
handler: () => {
|
|
238
|
-
console.log('Manage list layouts
|
|
239
|
-
console.log('');
|
|
240
|
-
console.log('Available subcommands:');
|
|
241
|
-
console.log(' list <table> List list views for a table');
|
|
242
|
-
console.log(' show <table> Show list layout for a table');
|
|
98
|
+
console.log('Manage list layouts.');
|
|
243
99
|
console.log('');
|
|
244
|
-
console.log('
|
|
100
|
+
console.log('Commands:');
|
|
101
|
+
console.log(' list [table] List views (pick table if omitted)');
|
|
102
|
+
console.log(' show <table> Show list layout columns');
|
|
245
103
|
},
|
|
246
104
|
};
|
|
247
105
|
}
|
|
106
|
+
|
|
107
|
+
async function showList(app, table, viewName, layoutSysID) {
|
|
108
|
+
const ep = new URLSearchParams();
|
|
109
|
+
ep.set('sysparm_limit', '100'); ep.set('sysparm_display_value', 'all');
|
|
110
|
+
ep.set('sysparm_fields', 'element,position,type');
|
|
111
|
+
ep.set('sysparm_query', `list_id=${layoutSysID}^ORDERBYposition`);
|
|
112
|
+
const elements = await app.sdk.list('sys_ui_list_element', ep);
|
|
113
|
+
|
|
114
|
+
elements.sort((a, b) => {
|
|
115
|
+
const pa = parseInt(getStringField(a, 'position') || '0', 10) || 0;
|
|
116
|
+
const pb = parseInt(getStringField(b, 'position') || '0', 10) || 0;
|
|
117
|
+
return pa - pb;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
app.ok({
|
|
121
|
+
table,
|
|
122
|
+
view: viewName,
|
|
123
|
+
columns: elements.map(e => ({
|
|
124
|
+
element: getStringField(e, 'element'),
|
|
125
|
+
position: parseInt(getStringField(e, 'position') || '0', 10) || 0,
|
|
126
|
+
type: getStringField(e, 'type') || undefined,
|
|
127
|
+
})),
|
|
128
|
+
_context: { instance_url: app.getEffectiveInstance(), table: 'sys_ui_list' },
|
|
129
|
+
}, { summary: `${table} (${viewName}) — ${elements.length} columns` });
|
|
130
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { buildDevCmd } from './_generic.js';
|
|
2
|
+
import { getStringField } from '../../helpers.js';
|
|
3
|
+
|
|
4
|
+
export const uipoliciesCmd = (wrap) => buildDevCmd('uipolicies', 'sys_ui_policy', ['uipolicy', 'up'],
|
|
5
|
+
['short_description', 'table', 'sys_class_name', 'active'], wrap, {
|
|
6
|
+
singular: 'UI policy',
|
|
7
|
+
scopeValidation: true,
|
|
8
|
+
onShow: async (record, app) => {
|
|
9
|
+
const sysID = getStringField(record, 'sys_id') || '';
|
|
10
|
+
if (!sysID) return;
|
|
11
|
+
const mp = new URLSearchParams();
|
|
12
|
+
mp.set('sysparm_query', `ui_policy=${sysID}^ORDERBYorder`);
|
|
13
|
+
mp.set('sysparm_limit', '100'); mp.set('sysparm_display_value', 'all');
|
|
14
|
+
mp.set('sysparm_fields', 'name,table,field_name,read_only,mandatory,visible,sys_id');
|
|
15
|
+
const actions = await app.sdk.list('sys_ui_policy_action', mp);
|
|
16
|
+
if (actions.length > 0) {
|
|
17
|
+
console.log(` Actions (${actions.length}):`);
|
|
18
|
+
for (const a of actions) {
|
|
19
|
+
const f = getStringField(a, 'field_name') || '(all fields)';
|
|
20
|
+
const flags = [];
|
|
21
|
+
if (getStringField(a, 'mandatory') === 'true') flags.push('mandatory');
|
|
22
|
+
if (getStringField(a, 'read_only') === 'true') flags.push('read-only');
|
|
23
|
+
if (getStringField(a, 'visible') === 'false') flags.push('hidden');
|
|
24
|
+
console.log(` ${f} ${flags.length ? `[${flags.join(', ')}]` : ''}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
package/src/commands/dev.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
|
|
8
8
|
aclsCmd, rolesCmd, propertiesCmd,
|
|
9
9
|
relationshipsCmd, appmodulesCmd, listcontrolsCmd, viewsCmd,
|
|
10
|
-
privilegesCmd,
|
|
10
|
+
privilegesCmd, uxscriptsCmd, aliasesCmd,
|
|
11
11
|
catalogscriptsCmd, cataloguipoliciesCmd,
|
|
12
12
|
} from './dev/_simple.js';
|
|
13
13
|
import { flowsCmd } from './dev/flows.js';
|
|
@@ -53,7 +53,6 @@ export function devCmd(wrap) {
|
|
|
53
53
|
.command(listcontrolsCmd(wrap))
|
|
54
54
|
.command(viewsCmd(wrap))
|
|
55
55
|
.command(privilegesCmd(wrap))
|
|
56
|
-
.command(securitytypesCmd(wrap))
|
|
57
56
|
.command(uxscriptsCmd(wrap))
|
|
58
57
|
.command(aliasesCmd(wrap))
|
|
59
58
|
.command(catalogscriptsCmd(wrap))
|
package/src/commands/groups.js
CHANGED
|
@@ -24,8 +24,30 @@ export function groupsCmd(wrap) {
|
|
|
24
24
|
formatLabel: r => `${getStringField(r, 'name')} ${getStringField(r, 'manager') ? '→ ' + getStringField(r, 'manager') : ''}`,
|
|
25
25
|
});
|
|
26
26
|
if (picked) {
|
|
27
|
+
const sysID = getStringField(picked, 'sys_id') || '';
|
|
28
|
+
// Fetch member + role counts for hints
|
|
29
|
+
let memberCount = 0, roleCount = 0;
|
|
30
|
+
try {
|
|
31
|
+
const cp = new URLSearchParams();
|
|
32
|
+
cp.set('sysparm_query', `group=${sysID}`); cp.set('sysparm_limit', '0'); cp.set('sysparm_fields', 'sys_id');
|
|
33
|
+
const members = await app.sdk.list('sys_user_grmember', cp);
|
|
34
|
+
memberCount = members.length;
|
|
35
|
+
} catch {}
|
|
36
|
+
try {
|
|
37
|
+
const rp = new URLSearchParams();
|
|
38
|
+
rp.set('sysparm_query', `group=${sysID}`); rp.set('sysparm_limit', '0'); rp.set('sysparm_fields', 'sys_id');
|
|
39
|
+
const roles = await app.sdk.list('sys_group_has_role', rp);
|
|
40
|
+
roleCount = roles.length;
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
27
43
|
picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user_group' };
|
|
28
|
-
return app.ok(picked, {
|
|
44
|
+
return app.ok(picked, {
|
|
45
|
+
summary: `Group: ${getStringField(picked, 'name')}`,
|
|
46
|
+
breadcrumbs: [
|
|
47
|
+
memberCount > 0 ? { action: 'members', cmd: `jsn records list --table sys_user_grmember --query "group=${sysID}"`, description: `${memberCount} member(s)` } : null,
|
|
48
|
+
roleCount > 0 ? { action: 'roles', cmd: `jsn records list --table sys_group_has_role --query "group=${sysID}"`, description: `${roleCount} role(s)` } : null,
|
|
49
|
+
].filter(Boolean),
|
|
50
|
+
});
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
const params = new URLSearchParams();
|
package/src/commands/skill.js
CHANGED
|
@@ -4,32 +4,86 @@ import os from 'node:os';
|
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
|
|
7
9
|
const SKILL_NAME = 'servicenow';
|
|
8
10
|
const SKILL_REPO_PATH = path.join(
|
|
9
11
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
10
12
|
'..', '..', 'skills', SKILL_NAME, 'SKILL.md'
|
|
11
13
|
);
|
|
12
14
|
|
|
13
|
-
const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME, 'SKILL.md');
|
|
14
|
-
|
|
15
15
|
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the user's real home directory, even when Hermes overrides $HOME.
|
|
19
|
+
*/
|
|
20
|
+
function realHomeDir() {
|
|
21
|
+
try {
|
|
22
|
+
const result = execSync('getent passwd ' + process.env.USER + ' 2>/dev/null', { encoding: 'utf-8', timeout: 3000 });
|
|
23
|
+
const home = result.trim().split(':')[5];
|
|
24
|
+
if (home) return home;
|
|
25
|
+
} catch {
|
|
26
|
+
// fall through
|
|
27
|
+
}
|
|
28
|
+
return os.homedir();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the Hermes profile skill directory.
|
|
33
|
+
* Checks HERMES_PROFILE env var, then scans ~/.hermes/profiles/ for the first
|
|
34
|
+
* profile directory, falling back to ~/.hermes/skills/ (legacy symlink path).
|
|
35
|
+
*/
|
|
36
|
+
function hermesSkillDir() {
|
|
37
|
+
const base = path.join(realHomeDir(), '.hermes');
|
|
38
|
+
const profileEnv = process.env.HERMES_PROFILE;
|
|
39
|
+
let profileName = profileEnv;
|
|
40
|
+
|
|
41
|
+
if (!profileName) {
|
|
42
|
+
try {
|
|
43
|
+
const profiles = fs.readdirSync(path.join(base, 'profiles'));
|
|
44
|
+
// Pick the first real profile directory (skip . and ..)
|
|
45
|
+
profileName = profiles.find(d => d !== '.' && d !== '..') || null;
|
|
46
|
+
} catch {
|
|
47
|
+
profileName = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (profileName) {
|
|
52
|
+
return path.join(base, 'profiles', profileName, 'skills', SKILL_NAME);
|
|
53
|
+
}
|
|
54
|
+
return path.join(base, 'skills', SKILL_NAME);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve the installed SKILL.md path for Hermes. */
|
|
58
|
+
function hermesSkillPath() {
|
|
59
|
+
return path.join(hermesSkillDir(), 'SKILL.md');
|
|
60
|
+
}
|
|
61
|
+
|
|
17
62
|
// Known agent skill directories (personal/user-level)
|
|
18
63
|
// Keyed by agent name for the --target flag
|
|
64
|
+
// Each value is the directory for the <name>/SKILL.md file
|
|
19
65
|
const AGENT_SKILL_DIRS = {
|
|
20
|
-
hermes:
|
|
21
|
-
copilot: path.join(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
66
|
+
hermes: hermesSkillDir(),
|
|
67
|
+
copilot: path.join(realHomeDir(), '.copilot', 'skills', SKILL_NAME),
|
|
68
|
+
vscode: path.join(realHomeDir(), '.copilot', 'instructions', SKILL_NAME),
|
|
69
|
+
claude: path.join(realHomeDir(), '.claude', 'skills', SKILL_NAME),
|
|
70
|
+
cursor: path.join(realHomeDir(), '.cursor', 'rules'),
|
|
71
|
+
agents: path.join(realHomeDir(), '.agents', 'skills', SKILL_NAME),
|
|
72
|
+
opencode: path.join(realHomeDir(), '.config', 'opencode', 'skills', SKILL_NAME),
|
|
73
|
+
openclaw: path.join(realHomeDir(), '.openclaw', 'skills', SKILL_NAME),
|
|
74
|
+
codex: path.join(realHomeDir(), '.codex', 'skills', SKILL_NAME),
|
|
25
75
|
};
|
|
26
76
|
|
|
27
77
|
const TARGET_NAMES = {
|
|
28
78
|
hermes: 'Hermes Agent',
|
|
29
79
|
copilot: 'GitHub Copilot',
|
|
80
|
+
vscode: 'VS Code (Copilot Instructions)',
|
|
30
81
|
claude: 'Claude Code',
|
|
31
82
|
cursor: 'Cursor',
|
|
32
83
|
agents: 'Agents (open standard)',
|
|
84
|
+
opencode: 'OpenCode',
|
|
85
|
+
openclaw: 'OpenClaw',
|
|
86
|
+
codex: 'Codex CLI',
|
|
33
87
|
};
|
|
34
88
|
|
|
35
89
|
function readBundledSkill() {
|
|
@@ -40,21 +94,32 @@ function readBundledSkill() {
|
|
|
40
94
|
}
|
|
41
95
|
}
|
|
42
96
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
97
|
+
export async function checkSkill() {
|
|
98
|
+
// Compare all installed skill copies against GitHub.
|
|
99
|
+
// Uses a single HTTP fetch and reads all harness paths that exist.
|
|
100
|
+
// Returns the first installed version found for comparison, plus a list
|
|
101
|
+
// of harnesses where the skill is current vs outdated (or missing).
|
|
102
|
+
const SKILL_NAME = 'servicenow';
|
|
103
|
+
|
|
104
|
+
// Collect all installed copies that actually exist on disk
|
|
105
|
+
const installedCopies = [];
|
|
106
|
+
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
107
|
+
const skillPath = key === 'cursor'
|
|
108
|
+
? path.join(dir, `${SKILL_NAME}.mdc`)
|
|
109
|
+
: path.join(dir, 'SKILL.md');
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(skillPath, 'utf-8');
|
|
112
|
+
installedCopies.push({ harness: key, label: TARGET_NAMES[key], content, path: skillPath });
|
|
113
|
+
} catch {
|
|
114
|
+
// Not installed to this harness — skip
|
|
115
|
+
}
|
|
48
116
|
}
|
|
49
|
-
}
|
|
50
117
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const installed = readInstalledSkill() || readBundledSkill();
|
|
55
|
-
if (!installed) return { current: false, error: 'Skill file not found' };
|
|
118
|
+
// Fall back to bundled skill if nothing installed yet
|
|
119
|
+
const primary = installedCopies[0] || { content: readBundledSkill() };
|
|
120
|
+
if (!primary.content) return { current: false, error: 'Skill file not found' };
|
|
56
121
|
|
|
57
|
-
const installedVersion = extractVersion(
|
|
122
|
+
const installedVersion = extractVersion(primary.content);
|
|
58
123
|
if (!installedVersion) return { current: false, error: 'No version field in installed skill' };
|
|
59
124
|
|
|
60
125
|
try {
|
|
@@ -68,11 +133,30 @@ export async function checkSkill() {
|
|
|
68
133
|
if (!upstreamVersion) return { current: false, error: 'No version field in upstream skill' };
|
|
69
134
|
|
|
70
135
|
const current = installedVersion === upstreamVersion;
|
|
136
|
+
|
|
137
|
+
// Check each installed copy for version match
|
|
138
|
+
const outdated = [];
|
|
139
|
+
for (const copy of installedCopies) {
|
|
140
|
+
const v = extractVersion(copy.content);
|
|
141
|
+
if (v && v !== upstreamVersion) {
|
|
142
|
+
outdated.push(copy.label);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const outdatedMsg = outdated.length > 0
|
|
147
|
+
? ` Skill outdated for ${outdated.join(', ')} (v${installedVersion} vs GitHub v${upstreamVersion}) — run "jsn skill install" to update`
|
|
148
|
+
: null;
|
|
149
|
+
|
|
71
150
|
return {
|
|
72
151
|
current,
|
|
73
152
|
installed_version: installedVersion,
|
|
74
153
|
upstream_version: upstreamVersion,
|
|
75
|
-
|
|
154
|
+
checked: installedCopies.length,
|
|
155
|
+
harnesses: {
|
|
156
|
+
installed: installedCopies.map(c => c.label),
|
|
157
|
+
outdated,
|
|
158
|
+
},
|
|
159
|
+
error: current ? null : outdatedMsg,
|
|
76
160
|
};
|
|
77
161
|
} catch {
|
|
78
162
|
return { current: false, error: 'Could not check — GitHub unreachable' };
|
|
@@ -85,6 +169,9 @@ function extractVersion(content) {
|
|
|
85
169
|
return m ? m[1] : null;
|
|
86
170
|
}
|
|
87
171
|
|
|
172
|
+
// Exported for testing
|
|
173
|
+
export { realHomeDir, hermesSkillDir, extractVersion, AGENT_SKILL_DIRS, TARGET_NAMES };
|
|
174
|
+
|
|
88
175
|
export function skillCmd(wrap) {
|
|
89
176
|
return {
|
|
90
177
|
command: 'skill',
|
|
@@ -112,13 +199,28 @@ export function skillCmd(wrap) {
|
|
|
112
199
|
describe: 'Check if the bundled skill file is up to date with GitHub',
|
|
113
200
|
handler: wrap(async (_argv, app) => {
|
|
114
201
|
const result = await checkSkill();
|
|
202
|
+
const h = result.harnesses || {};
|
|
203
|
+
|
|
204
|
+
// Build a summary that shows which harnesses are installed and current
|
|
205
|
+
let summary;
|
|
115
206
|
if (result.error && !result.current) {
|
|
116
|
-
|
|
207
|
+
summary = result.error;
|
|
117
208
|
} else if (result.current) {
|
|
118
|
-
|
|
209
|
+
const parts = [`✓ Skill is current (v${result.installed_version})`];
|
|
210
|
+
if (h.installed && h.installed.length > 0) {
|
|
211
|
+
parts.push(` — installed for ${h.installed.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
summary = parts.join('');
|
|
119
214
|
} else {
|
|
120
|
-
|
|
215
|
+
const parts = [`⚠ Skill outdated: installed v${result.installed_version} vs GitHub v${result.upstream_version}`];
|
|
216
|
+
if (h.outdated && h.outdated.length > 0) {
|
|
217
|
+
parts.push(` — outdated for ${h.outdated.join(', ')}`);
|
|
218
|
+
}
|
|
219
|
+
parts.push(` — run "jsn skill install" to update`);
|
|
220
|
+
summary = parts.join('');
|
|
121
221
|
}
|
|
222
|
+
|
|
223
|
+
app.ok(result, { summary });
|
|
122
224
|
}),
|
|
123
225
|
})
|
|
124
226
|
.command({
|
|
@@ -148,7 +250,7 @@ export function skillCmd(wrap) {
|
|
|
148
250
|
}, {
|
|
149
251
|
summary: 'Skill file locations and install targets',
|
|
150
252
|
breadcrumbs: [
|
|
151
|
-
{ action: 'install', cmd: 'jsn skill install', description: '
|
|
253
|
+
{ action: 'install', cmd: 'jsn skill install', description: 'Interactive picker for install targets' },
|
|
152
254
|
{ action: 'install-all', cmd: 'jsn skill install --target all', description: 'Install to all supported agents' },
|
|
153
255
|
{ action: 'install-copilot', cmd: 'jsn skill install --target copilot', description: 'Install for GitHub Copilot' },
|
|
154
256
|
{ action: 'install-claude', cmd: 'jsn skill install --target claude', description: 'Install for Claude Code' },
|
|
@@ -166,8 +268,7 @@ export function skillCmd(wrap) {
|
|
|
166
268
|
})
|
|
167
269
|
.option('target', {
|
|
168
270
|
type: 'string',
|
|
169
|
-
describe: 'Target agent(s): hermes, copilot, claude, cursor, agents, or "all" (comma-separated)',
|
|
170
|
-
default: 'hermes',
|
|
271
|
+
describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit for interactive picker.',
|
|
171
272
|
}),
|
|
172
273
|
handler: wrap(async (argv, app) => {
|
|
173
274
|
const res = await fetch(SKILL_RAW_URL);
|
|
@@ -184,7 +285,8 @@ export function skillCmd(wrap) {
|
|
|
184
285
|
const targetPath = path.join(p, 'SKILL.md');
|
|
185
286
|
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
186
287
|
targets.push({ name: path.basename(argv.dir), path: targetPath });
|
|
187
|
-
} else {
|
|
288
|
+
} else if (argv.target) {
|
|
289
|
+
// Explicit --target: install to specified agents
|
|
188
290
|
const rawTargets = argv.target.split(',').map(t => t.trim().toLowerCase());
|
|
189
291
|
const all = rawTargets.includes('all');
|
|
190
292
|
|
|
@@ -204,6 +306,80 @@ export function skillCmd(wrap) {
|
|
|
204
306
|
}
|
|
205
307
|
}
|
|
206
308
|
}
|
|
309
|
+
} else if (process.stdin.isTTY) {
|
|
310
|
+
// Interactive: show multi-select picker
|
|
311
|
+
const fetchedVersion = extractVersion(content);
|
|
312
|
+
// Check if any existing installed copies are outdated
|
|
313
|
+
const outdated = [];
|
|
314
|
+
if (fetchedVersion) {
|
|
315
|
+
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
316
|
+
const skillPath = key === 'cursor'
|
|
317
|
+
? path.join(dir, `${SKILL_NAME}.mdc`)
|
|
318
|
+
: path.join(dir, 'SKILL.md');
|
|
319
|
+
try {
|
|
320
|
+
const existing = fs.readFileSync(skillPath, 'utf-8');
|
|
321
|
+
const existingVersion = extractVersion(existing);
|
|
322
|
+
if (existingVersion && existingVersion !== fetchedVersion) {
|
|
323
|
+
outdated.push(TARGET_NAMES[key]);
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
// Not installed — skip
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const promptMsg = outdated.length > 0
|
|
331
|
+
? `Where should the jsn skill be installed? (${outdated.length} outdated)`
|
|
332
|
+
: 'Where should the jsn skill be installed?';
|
|
333
|
+
|
|
334
|
+
const { default: checkbox } = await import('@inquirer/checkbox');
|
|
335
|
+
const choices = Object.entries(TARGET_NAMES).map(([key, label]) => {
|
|
336
|
+
const dir = AGENT_SKILL_DIRS[key];
|
|
337
|
+
const targetPath = key === 'cursor'
|
|
338
|
+
? path.join(dir, `${SKILL_NAME}.mdc`)
|
|
339
|
+
: path.join(dir, 'SKILL.md');
|
|
340
|
+
// Show tildified path for readability
|
|
341
|
+
const home = realHomeDir();
|
|
342
|
+
const displayPath = targetPath.replace(home, '~');
|
|
343
|
+
return {
|
|
344
|
+
name: label,
|
|
345
|
+
value: key,
|
|
346
|
+
description: displayPath,
|
|
347
|
+
// Pre-check agents whose skill dirs already exist
|
|
348
|
+
checked: fs.existsSync(AGENT_SKILL_DIRS[key]),
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
const selected = await checkbox({
|
|
352
|
+
message: promptMsg,
|
|
353
|
+
choices,
|
|
354
|
+
instructions: '(space to toggle, enter to confirm)',
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (selected.length === 0) {
|
|
358
|
+
// Fall back to Hermes if nothing selected
|
|
359
|
+
selected.push('hermes');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const key of selected) {
|
|
363
|
+
const dir = AGENT_SKILL_DIRS[key];
|
|
364
|
+
if (key === 'cursor') {
|
|
365
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
366
|
+
const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
|
|
367
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
368
|
+
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
369
|
+
} else {
|
|
370
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
371
|
+
const targetPath = path.join(dir, 'SKILL.md');
|
|
372
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
373
|
+
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
// Non-interactive, no --target: install to Hermes only (backward compat)
|
|
378
|
+
const dir = AGENT_SKILL_DIRS.hermes;
|
|
379
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
380
|
+
const targetPath = path.join(dir, 'SKILL.md');
|
|
381
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
382
|
+
targets.push({ name: TARGET_NAMES.hermes, path: targetPath });
|
|
207
383
|
}
|
|
208
384
|
|
|
209
385
|
if (targets.length === 0) {
|
package/src/commands/users.js
CHANGED
|
@@ -24,8 +24,15 @@ export function usersCmd(wrap) {
|
|
|
24
24
|
formatLabel: r => `${getStringField(r, 'user_name')} (${getStringField(r, 'name') || '-'})`,
|
|
25
25
|
});
|
|
26
26
|
if (picked) {
|
|
27
|
+
const sysID = getStringField(picked, 'sys_id') || '';
|
|
27
28
|
picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user' };
|
|
28
|
-
return app.ok(picked, {
|
|
29
|
+
return app.ok(picked, {
|
|
30
|
+
summary: `User: ${getStringField(picked, 'user_name')}`,
|
|
31
|
+
breadcrumbs: [
|
|
32
|
+
{ action: 'preferences', cmd: `jsn records list --table sys_user_preference --query "user=${sysID}"`, description: 'User preferences' },
|
|
33
|
+
{ action: 'groups', cmd: `jsn records list --table sys_user_grmember --query "user=${sysID}"`, description: 'Group memberships' },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
const params = new URLSearchParams();
|
package/src/commands/version.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
const NPM_PKG = '@jacebenson/jsn';
|
|
6
6
|
const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
|
|
7
7
|
|
|
8
|
-
function getVersion() {
|
|
8
|
+
export function getVersion() {
|
|
9
9
|
try {
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
|
@@ -16,7 +16,7 @@ function getVersion() {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function checkLatest() {
|
|
19
|
+
export async function checkLatest() {
|
|
20
20
|
try {
|
|
21
21
|
const res = await fetch(NPM_REGISTRY);
|
|
22
22
|
if (!res.ok) return null;
|
package/src/help.js
CHANGED
|
@@ -53,7 +53,6 @@ const ACCESS_COMMANDS = [
|
|
|
53
53
|
{ name: 'b4rules', alias: 'b4r', desc: 'Before-query business rules (see also Automation)' },
|
|
54
54
|
{ name: 'roles', alias: 'role', desc: 'Roles' },
|
|
55
55
|
{ name: 'privileges', alias: 'priv', desc: 'Scope privileges' },
|
|
56
|
-
{ name: 'securitytypes', alias: 'st', desc: 'Custom security types' },
|
|
57
56
|
{ name: 'aliases', alias: 'als', desc: 'Connection & credential aliases' },
|
|
58
57
|
{ name: 'groups', alias: 'group', desc: 'Manage groups' },
|
|
59
58
|
{ name: 'groupmembers', alias: 'gm', desc: 'Group memberships' },
|
package/src/output.js
CHANGED
|
@@ -460,6 +460,9 @@ export function getTableURL(instance, table, sysID) {
|
|
|
460
460
|
sysevent_email_action: `nav_to.do?uri=sysevent_email_action.do?sys_id=${sysID}`,
|
|
461
461
|
sysevent_in_email_action: `nav_to.do?uri=sysevent_in_email_action.do?sys_id=${sysID}`,
|
|
462
462
|
soap_message: `nav_to.do?uri=soap_message.do?sys_id=${sysID}`,
|
|
463
|
+
sys_security_acl: `nav_to.do?uri=sys_security_acl.do?sys_id=${sysID}`,
|
|
464
|
+
sys_user_role: `nav_to.do?uri=sys_user_role.do?sys_id=${sysID}`,
|
|
465
|
+
sys_db_object: `nav_to.do?uri=sys_db_object.do?sys_id=${sysID}`,
|
|
463
466
|
};
|
|
464
467
|
const path = mapped[table] || `${table}.do?sys_id=${sysID}`;
|
|
465
468
|
return `${instance}/${path}`;
|