@jacebenson/jsn 4.0.4 → 4.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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -3
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -65
  7. package/src/commands/_ticket.js +37 -93
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +25 -81
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +63 -111
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +65 -7
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +274 -12
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +107 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +131 -15
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +195 -64
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
@@ -0,0 +1,103 @@
1
+ // jsn codesearch — search across ServiceNow code artifacts (script includes,
2
+ // business rules, client scripts, UI actions, ...) via the sn_codesearch
3
+ // plugin REST API. Issue #180.
4
+
5
+ import { errUsage } from '../errors.js';
6
+
7
+ const DEFAULT_SEARCH_GROUP = 'sn_devstudio.Studio Search Group';
8
+
9
+ // Flatten the API's nested shape into one row per record/field so the output
10
+ // controller can render it as a table / CSV / JSON.
11
+ //
12
+ // API shape:
13
+ // result: [ { recordType, hits: [ { name, className, tableLabel,
14
+ // matches: [ { field, fieldLabel,
15
+ // lineMatches: [ { line, context, escaped } ] } ] } ] } ] }
16
+ // NOTE: with ?table=<t> the API returns a single object, not an array.
17
+ export function flattenHits(result) {
18
+ const groups = Array.isArray(result) ? result : (result ? [result] : []);
19
+ const rows = [];
20
+ for (const group of groups) {
21
+ for (const hit of group.hits || []) {
22
+ for (const match of hit.matches || []) {
23
+ const lines = (match.lineMatches || []).filter((lm) => lm.context);
24
+ rows.push({
25
+ table: hit.className || group.recordType,
26
+ name: hit.name,
27
+ field: match.field,
28
+ lines: lines.map((lm) => lm.line).join(','),
29
+ matches: lines.length,
30
+ context: lines[0]?.context?.trim() || '',
31
+ sys_id: hit.sys_id || hit.sysId || '',
32
+ });
33
+ }
34
+ }
35
+ }
36
+ return rows;
37
+ }
38
+
39
+ // Pre-rendered text view for styled/terminal output (output.js uses
40
+ // data._formatted when present).
41
+ function renderFormatted(rows, term) {
42
+ if (rows.length === 0) return `No code matches for "${term}".\n`;
43
+ const out = [];
44
+ for (const r of rows) {
45
+ out.push(`${r.table} ${r.name} .${r.field} (lines ${r.lines})`);
46
+ if (r.context) out.push(` ${r.context}`);
47
+ }
48
+ return out.join('\n') + '\n';
49
+ }
50
+
51
+ export function codesearchCmd(wrap) {
52
+ return {
53
+ command: 'codesearch [subcommand]',
54
+ aliases: ['codesearch'],
55
+ describe: 'Search code across ServiceNow artifacts (script includes, business rules, client scripts, ...)',
56
+ builder: (yargs) => yargs
57
+ .command({
58
+ command: 'search <term>',
59
+ describe: 'Search code for a term (e.g. "codesearch search GlideAjax")',
60
+ builder: (y) => y
61
+ .positional('term', { type: 'string', describe: 'Search term' })
62
+ .option('limit', { alias: 'l', type: 'number', default: 100, describe: 'Max results' })
63
+ .option('table', { alias: 't', type: 'string', describe: 'Restrict to one source table (e.g. sys_script_include)' })
64
+ .option('scope', { alias: 's', type: 'string', describe: 'Restrict to an app scope (e.g. x_my_app)' })
65
+ .option('search-group', { type: 'string', default: DEFAULT_SEARCH_GROUP, describe: 'Search group to use' })
66
+ .option('all-scopes', { type: 'boolean', default: true, describe: 'Search across all scopes (default true)' }),
67
+ handler: wrap(async (argv, app) => {
68
+ if (!argv.term || !argv.term.trim()) {
69
+ throw errUsage('A search term is required', 'Example: jsn codesearch search GlideAjax');
70
+ }
71
+
72
+ const params = new URLSearchParams();
73
+ params.set('term', argv.term);
74
+ params.set('limit', String(argv.limit));
75
+ params.set('search_group', argv.searchGroup);
76
+ params.set('search_all_scopes', String(argv.allScopes));
77
+ if (argv.table) params.set('table', argv.table);
78
+ if (argv.scope) params.set('scope', argv.scope);
79
+
80
+ const url = `${app.getEffectiveInstance()}/api/sn_codesearch/code_search/search?${params.toString()}`;
81
+ let payload;
82
+ try {
83
+ payload = await app.sdk.request(url);
84
+ } catch (err) {
85
+ if (err.code === 'api_error' && /404/.test(err.message)) {
86
+ err.message += '\n\nHint: the sn_codesearch plugin may not be active on this instance.';
87
+ }
88
+ throw err;
89
+ }
90
+
91
+ const result = payload?.result || [];
92
+ const rows = flattenHits(result);
93
+
94
+ app.ok(
95
+ { term: argv.term, count: rows.length, records: rows, _formatted: renderFormatted(rows, argv.term) },
96
+ { summary: `${rows.length} code match(es) for "${argv.term}"` },
97
+ );
98
+ }),
99
+ })
100
+ .demandCommand(1, 'Specify a subcommand: search'),
101
+ handler: () => {},
102
+ };
103
+ }
@@ -1,9 +1,10 @@
1
1
  // Generic dev subcommand builder for table-based CRUD
2
2
 
3
- import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch } from '../../helpers.js';
3
+ import { formatRecordForDisplay, getStringField, parseDataArg, confirmDelete, interactiveList, canPrompt } from '../../helpers.js';
4
+ import { resolveRecord, unwrapSysId } from '../../resolve-record.js';
4
5
  import { getCurrentUser, getCurrentApplication } from '../../context.js';
5
- import { paginatedSearch } from '../../paginated-search.js';
6
- import { isTTY, FormatAuto } from '../../output.js';
6
+ import { FormatAuto } from '../../output.js';
7
+ import { declareCapabilities } from '../../capabilities.js';
7
8
 
8
9
  function vowelArticle(word) {
9
10
  const first = word.charAt(0).toLowerCase();
@@ -75,10 +76,31 @@ async function resolveRecordScope(sdk, record) {
75
76
  }
76
77
  }
77
78
 
79
+ /**
80
+ * Scope guard for update/delete on scoped tables: when scopeValidation is
81
+ * enabled and the record lives outside the current scope, refuse with a
82
+ * "switch scope first" error. Pass the resolved record straight through.
83
+ */
84
+ async function guardScope(sdk, record) {
85
+ const recordScope = await resolveRecordScope(sdk, record);
86
+ const scopeErr = await checkScope(sdk, recordScope);
87
+ if (scopeErr) {
88
+ throw new Error(`record is in scope '${scopeErr.recordScope}', but your current scope is '${scopeErr.currentScope}'. Switch scope first: jsn scopes set ${scopeErr.recordScope}`);
89
+ }
90
+ return record;
91
+ }
92
+
78
93
  export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {}) {
79
94
  const showFields = opts.showFields !== undefined ? opts.showFields : null;
80
95
  const singular = toSingular(name, opts.singular);
81
96
  const readOnly = opts.readOnly || false;
97
+ // Capability declaration: the readOnly flag drives the mutation registry.
98
+ // buildDevCmd commands are registered both at root and under `jsn dev`
99
+ // unless opts.devAlias says otherwise (root-only commands pass false).
100
+ declareCapabilities(name, {
101
+ mutationSubcommands: readOnly ? [] : ['create', 'update', 'delete'],
102
+ devAlias: opts.devAlias !== false,
103
+ });
82
104
  const scopeValidation = opts.scopeValidation || false;
83
105
  const extraQuery = opts.extraQuery || '';
84
106
  const showSummary = opts.showSummary || ((record, id) => `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${getStringField(record, 'name') || id}`);
@@ -111,66 +133,32 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
111
133
  const limit = argv.limit;
112
134
 
113
135
  // Interactive picker with pagination
114
- const interactive = isTTY(process.stdout) && isTTY(process.stdin);
115
- if (interactive && app.output.getFormat() === FormatAuto && !query) {
116
- // Get total count
117
- let totalCount = 0;
118
- try {
119
- totalCount = await app.sdk.aggregateCount(table, extraQuery || '');
120
- } catch { /* non-critical */ }
121
-
122
- const source = async (term, offset, { signal: _signal } = {}) => {
123
- const params = new URLSearchParams();
124
- params.set('sysparm_limit', String(limit));
125
- params.set('sysparm_display_value', 'all');
126
- params.set('sysparm_fields', ['sys_id', 'name', 'sys_scope'].join(','));
127
- if (term) {
128
- params.set('sysparm_query', `nameLIKE${term}^ORDERBYDESCsys_updated_on`);
129
- } else {
130
- params.set('sysparm_query', (extraQuery ? extraQuery + '^' : '') + 'ORDERBYDESCsys_updated_on');
131
- params.set('sysparm_offset', String(offset));
132
- }
133
- const records = await app.sdk.list(table, params);
134
- return records.map(r => {
136
+ if (canPrompt() && app.output.getFormat() === FormatAuto && !query) {
137
+ const picked = await interactiveList({
138
+ app, table, singular, columns: ['name', 'sys_scope'], limit, query: extraQuery,
139
+ labelField: 'name',
140
+ message: `Select ${vowelArticle(singular)} ${singular}`,
141
+ formatLabel: (r) => {
135
142
  const recordName = getStringField(r, 'name') || getStringField(r, 'sys_id');
136
143
  const scope = getStringField(r, 'sys_scope') || '';
137
- let label = recordName;
138
- if (scope && scope !== 'global') label += ` [${scope}]`;
139
- return { name: label, value: recordName };
140
- });
141
- };
142
-
143
- const selectedName = await paginatedSearch({
144
- message: `Select ${vowelArticle(singular)} ${singular}`,
145
- pageSize: 10,
146
- totalCount,
147
- source,
144
+ return (scope && scope !== 'global') ? `${recordName} [${scope}]` : recordName;
145
+ },
148
146
  });
147
+ if (picked === undefined || picked === null) return; // cancelled, or table is empty
149
148
 
150
- if (selectedName) {
151
- const recordName = typeof selectedName === 'object' ? selectedName.value : selectedName;
152
- assertSafeExactMatch(recordName);
153
- const showParams = new URLSearchParams();
154
- showParams.set('sysparm_query', `name=${recordName}`);
155
- showParams.set('sysparm_display_value', 'all');
156
- showParams.set('sysparm_limit', '1');
157
- const showRecords = await app.sdk.list(table, showParams);
158
- if (showRecords.length === 0) {
159
- throw new Error(`${singular} not found: ${recordName}`);
160
- }
161
- const record = showRecords[0];
162
- record._context = { instance_url: app.getEffectiveInstance(), table };
163
- if (opts.onShow) {
164
- await opts.onShow(record, app);
165
- }
166
- app.ok(record, {
167
- summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
168
- breadcrumbs: [
169
- ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} --data '{...}'`, description: `Update this ${singular}` }]),
170
- { action: 'list', cmd: `jsn ${name} list`, description: `Back to all ${name}` },
171
- ],
172
- });
149
+ const record = picked;
150
+ const recordName = getStringField(record, 'name') || getStringField(record, 'sys_id');
151
+ record._context = { instance_url: app.getEffectiveInstance(), table };
152
+ if (opts.onShow) {
153
+ await opts.onShow(record, app);
173
154
  }
155
+ app.ok(record, {
156
+ summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
157
+ breadcrumbs: [
158
+ ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} --data '{...}'`, description: `Update this ${singular}` }]),
159
+ { action: 'list', cmd: `jsn ${name} list`, description: `Back to all ${name}` },
160
+ ],
161
+ });
174
162
  return;
175
163
  }
176
164
 
@@ -201,34 +189,25 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
201
189
  describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
202
190
  handler: wrap(async (argv, app) => {
203
191
  const id = argv.identifier;
204
- const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
205
- assertSafeExactMatch(id);
206
- const params = new URLSearchParams();
207
- params.set('sysparm_query', `${queryField}=${id}`);
208
- params.set('sysparm_limit', '1');
209
- params.set('sysparm_display_value', 'all');
210
192
  // Only restrict sysparm_fields if showFields is explicitly set.
211
193
  // Go version fetches all fields for show unless explicitly restricted.
212
- if (showFields && showFields.length > 0) {
213
- params.set('sysparm_fields', [...new Set(['sys_id', ...showFields])].join(','));
214
- }
215
- const records = await app.sdk.list(table, params);
216
- if (records.length === 0) {
217
- throw new Error(`${singular} not found: ${id}`);
218
- }
219
- records[0]._context = {
194
+ const fields = (showFields && showFields.length > 0)
195
+ ? [...new Set(['sys_id', ...showFields])]
196
+ : undefined;
197
+ const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular, fields });
198
+ record._context = {
220
199
  instance_url: app.getEffectiveInstance(),
221
200
  table,
222
201
  };
223
202
 
224
203
  if (opts.onShow) {
225
- await opts.onShow(records[0], app);
204
+ await opts.onShow(record, app);
226
205
  }
227
206
 
228
- const summary = typeof showSummary === 'function' ? showSummary(records[0], id) : showSummary;
229
- const breadcrumbs = typeof showBreadcrumbs === 'function' ? showBreadcrumbs(records[0], id) : showBreadcrumbs;
207
+ const summary = typeof showSummary === 'function' ? showSummary(record, id) : showSummary;
208
+ const breadcrumbs = typeof showBreadcrumbs === 'function' ? showBreadcrumbs(record, id) : showBreadcrumbs;
230
209
 
231
- app.ok(records[0], { summary, breadcrumbs });
210
+ app.ok(record, { summary, breadcrumbs });
232
211
  }),
233
212
  });
234
213
 
@@ -259,26 +238,12 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
259
238
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
260
239
  handler: wrap(async (argv, app) => {
261
240
  const id = argv.identifier;
262
- const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
263
- assertSafeExactMatch(id);
264
- const findParams = new URLSearchParams();
265
- findParams.set('sysparm_query', `${queryField}=${id}`);
266
- findParams.set('sysparm_limit', '1');
267
- findParams.set('sysparm_display_value', 'all');
268
- const records = await app.sdk.list(table, findParams);
269
- if (records.length === 0) {
270
- throw new Error(`${singular} not found: ${id}`);
271
- }
272
- const sysID = getStringField(records[0], 'sys_id');
273
- const recordScope = await resolveRecordScope(app.sdk, records[0]);
274
-
241
+ const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
275
242
  if (scopeValidation) {
276
- const scopeErr = await checkScope(app.sdk, recordScope);
277
- if (scopeErr) {
278
- throw new Error(`record is in scope '${scopeErr.recordScope}', but your current scope is '${scopeErr.currentScope}'. Switch scope first: jsn scopes set ${scopeErr.recordScope}`);
279
- }
243
+ await guardScope(app.sdk, record);
280
244
  }
281
245
 
246
+ const sysID = unwrapSysId(record);
282
247
  const recordData = parseDataArg(argv);
283
248
  const updated = await app.sdk.update(table, sysID, recordData);
284
249
  app.ok(updated, { summary: `Updated ${singular} ${id}` });
@@ -290,27 +255,14 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
290
255
  builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
291
256
  handler: wrap(async (argv, app) => {
292
257
  const id = argv.identifier;
293
- const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
294
- assertSafeExactMatch(id);
295
- const findParams = new URLSearchParams();
296
- findParams.set('sysparm_query', `${queryField}=${id}`);
297
- findParams.set('sysparm_limit', '1');
298
- findParams.set('sysparm_display_value', 'all');
299
- const records = await app.sdk.list(table, findParams);
300
- if (records.length === 0) {
301
- throw new Error(`${singular} not found: ${id}`);
302
- }
303
- const sysID = getStringField(records[0], 'sys_id');
304
- const recordName = getStringField(records[0], 'name') || id;
305
- const recordScope = await resolveRecordScope(app.sdk, records[0]);
306
-
258
+ const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
307
259
  if (scopeValidation) {
308
- const scopeErr = await checkScope(app.sdk, recordScope);
309
- if (scopeErr) {
310
- throw new Error(`record is in scope '${scopeErr.recordScope}', but your current scope is '${scopeErr.currentScope}'. Switch scope first: jsn scopes set ${scopeErr.recordScope}`);
311
- }
260
+ await guardScope(app.sdk, record);
312
261
  }
313
262
 
263
+ const sysID = unwrapSysId(record);
264
+ const recordName = getStringField(record, 'name') || id;
265
+
314
266
  await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
315
267
 
316
268
  await app.sdk.delete(table, sysID);
@@ -56,7 +56,12 @@ export const clientScriptsCmd = (wrap) => buildDevCmd('clientscripts', 'sys_scri
56
56
 
57
57
  export const uiActionsCmd = (wrap) => buildDevCmd('uiactions', 'sys_ui_action', ['uiaction', 'ua'], ['name', 'table', 'active', 'order', 'sys_scope'], wrap, { singular: 'UI action', scopeValidation: true });
58
58
 
59
- export const uiPoliciesCmd = (wrap) => buildDevCmd('uipolicies', 'sys_ui_policy', ['uipolicy', 'up'], ['short_description', 'table', 'active', 'order', 'sys_scope'], wrap, { singular: 'UI policy', scopeValidation: true });
59
+ // NOTE: uipolicies has exactly ONE definition — the rich one in
60
+ // ./uipolicies.js (re-exported below at the bottom of this file). A plain
61
+ // buildDevCmd('uipolicies', ...) factory used to live here, but yargs keeps
62
+ // the LAST registration of a duplicate command name, and cli.js registers
63
+ // this module's uiPoliciesCmd before uipoliciesCmd — so the factory version
64
+ // was dead code shadowing nothing. Deleted; the re-export is the behavior.
60
65
 
61
66
  export const tablesCmd = (wrap) => buildDevCmd('tables', 'sys_db_object', ['table', 't'], ['name', 'label', 'super_class', 'create_access_controls'], wrap, {
62
67
  singular: 'table',
@@ -98,28 +103,27 @@ export { aliasesCmd } from './aliases.js';
98
103
  export const catalogscriptsCmd = (wrap) => buildDevCmd('catalogscripts', 'catalog_script_client', ['catalogscript', 'cs'], ['name', 'table', 'active', 'sys_scope'], wrap, { singular: 'catalog script', scopeValidation: true });
99
104
 
100
105
  // ── Automation: Async ──
101
- export const scriptactionsCmd = (wrap) => buildDevCmd('scriptactions', 'sysevent_script_action', ['scriptaction'], ['name', 'active', 'sys_scope'], wrap, { singular: 'script action', scopeValidation: true });
102
- export const scheduledjobsCmd = (wrap) => buildDevCmd('scheduledjobs', 'sysauto_script', ['scheduledjob'], ['name', 'active', 'run_type', 'sys_scope'], wrap, { singular: 'scheduled job', scopeValidation: true });
103
- export const asyncrulesCmd = (wrap) => buildDevCmd('asyncrules', 'sys_script', ['asyncrule'], ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, { singular: 'async business rule', scopeValidation: true, extraQuery: 'when=async' });
104
- export const triggersCmd = (wrap) => buildDevCmd('triggers', 'sysevent_register', ['trigger'], ['name', 'event_name', 'active', 'sys_scope'], wrap, { singular: 'event trigger', scopeValidation: true });
106
+ export const scriptactionsCmd = (wrap) => buildDevCmd('scriptactions', 'sysevent_script_action', ['scriptaction'], ['name', 'active', 'sys_scope'], wrap, { singular: 'script action', scopeValidation: true, devAlias: false });
107
+ export const scheduledjobsCmd = (wrap) => buildDevCmd('scheduledjobs', 'sysauto_script', ['scheduledjob'], ['name', 'active', 'run_type', 'sys_scope'], wrap, { singular: 'scheduled job', scopeValidation: true, devAlias: false });
108
+ export const asyncrulesCmd = (wrap) => buildDevCmd('asyncrules', 'sys_script', ['asyncrule'], ['name', 'collection', 'active', 'order', 'sys_scope'], wrap, { singular: 'async business rule', scopeValidation: true, extraQuery: 'when=async', devAlias: false });
109
+ export const triggersCmd = (wrap) => buildDevCmd('triggers', 'sysevent_register', ['trigger'], ['name', 'event_name', 'active', 'sys_scope'], wrap, { singular: 'event trigger', scopeValidation: true, devAlias: false });
105
110
 
106
111
  // ── Automation: In Memory ──
107
- export const decisiontablesCmd = (wrap) => buildDevCmd('decisiontables', 'sys_ws_definition', ['decisiontable'], ['name', 'active', 'sys_scope'], wrap, { singular: 'decision table', scopeValidation: true, readOnly: true });
108
- export const assignmentsCmd = (wrap) => buildDevCmd('assignments', 'sysrule_assignment', ['assignment'], ['name', 'active', 'sys_scope'], wrap, { singular: 'assignment rule', scopeValidation: true, readOnly: true });
112
+ export const decisiontablesCmd = (wrap) => buildDevCmd('decisiontables', 'sys_ws_definition', ['decisiontable'], ['name', 'active', 'sys_scope'], wrap, { singular: 'decision table', scopeValidation: true, readOnly: true, devAlias: false });
113
+ export const assignmentsCmd = (wrap) => buildDevCmd('assignments', 'sysrule_assignment', ['assignment'], ['name', 'active', 'sys_scope'], wrap, { singular: 'assignment rule', scopeValidation: true, readOnly: true, devAlias: false });
109
114
 
110
115
  // ── Automation: Inbound ──
111
- export const emailCmd = (wrap) => buildDevCmd('email', 'sysevent_in_email_action', ['emails'], ['name', 'active', 'type', 'sys_scope'], wrap, { singular: 'inbound email action', scopeValidation: true });
116
+ export const emailCmd = (wrap) => buildDevCmd('email', 'sysevent_in_email_action', ['emails'], ['name', 'active', 'type', 'sys_scope'], wrap, { singular: 'inbound email action', scopeValidation: true, devAlias: false });
112
117
 
113
118
  // ── Automation: Outbound ──
114
119
  export { restmessageCmd } from './restmessage.js';
115
- export const restmethodsCmd = (wrap) => buildDevCmd('restmethods', 'sys_rest_message_fn', ['restmethod'], ['name', 'http_method', 'active', 'sys_scope'], wrap, { singular: 'REST method', scopeValidation: true });
116
120
  export { soapmessagesCmd } from './soapmessages.js';
117
121
 
118
122
  // ── UX: Core UI ──
119
- export const uimacrosCmd = (wrap) => buildDevCmd('uimacros', 'sys_ui_macro', ['uimacro'], ['name', 'active', 'sys_scope'], wrap, { singular: 'UI macro', scopeValidation: true });
123
+ export const uimacrosCmd = (wrap) => buildDevCmd('uimacros', 'sys_ui_macro', ['uimacro'], ['name', 'active', 'sys_scope'], wrap, { singular: 'UI macro', scopeValidation: true, devAlias: false });
120
124
 
121
125
  // ── UX: Workspaces ──
122
- export const uxlistsCmd = (wrap) => buildDevCmd('uxlists', 'sys_ux_list', ['uxlist'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace list', scopeValidation: true, readOnly: true });
123
- export const uxapplicabilityCmd = (wrap) => buildDevCmd('uxapplicability', 'sys_ux_applicability_m2m', ['uxapp'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace applicability', scopeValidation: true, readOnly: true });
126
+ export const uxlistsCmd = (wrap) => buildDevCmd('uxlists', 'sys_ux_list', ['uxlist'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace list', scopeValidation: true, readOnly: true, devAlias: false });
127
+ export const uxapplicabilityCmd = (wrap) => buildDevCmd('uxapplicability', 'sys_ux_applicability_m2m', ['uxapp'], ['name', 'active', 'sys_scope'], wrap, { singular: 'workspace applicability', scopeValidation: true, readOnly: true, devAlias: false });
124
128
  export { uipoliciesCmd } from './uipolicies.js';
125
129
  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 });
@@ -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: 'when=before' });
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', devAlias: false });
@@ -11,6 +11,10 @@
11
11
  // to its own picker sessions.
12
12
 
13
13
  import { getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
14
+ import { declareCapabilities } from '../../capabilities.js';
15
+
16
+ // `domains set` writes the chosen domain into the profile config.
17
+ declareCapabilities('domains', { mutationSubcommands: ['set'], devAlias: true });
14
18
 
15
19
  const DETAIL_FIELDS = 'sys_id,name,parent,sys_domain_path,description,active,sys_created_on,sys_updated_on';
16
20
 
@@ -1,4 +1,10 @@
1
1
  import fs from 'node:fs';
2
+ import { getCurrentUser, getCurrentApplication } from '../../context.js';
3
+ import { declareCapabilities } from '../../capabilities.js';
4
+
5
+ // The whole command is a mutation surface (runs arbitrary server-side JS) —
6
+ // declared as `['']` so both `jsn eval` and `jsn dev eval` are gated.
7
+ declareCapabilities('eval', { mutationSubcommands: [''], devAlias: true });
2
8
 
3
9
  export function evalCmd(wrap) {
4
10
  return {
@@ -6,27 +12,71 @@ export function evalCmd(wrap) {
6
12
  describe: 'Execute background scripts on the instance',
7
13
  builder: (yargs) => {
8
14
  return yargs
9
- .option('script', { alias: 's', type: 'string', describe: "JavaScript code to execute (single-quote the script, double quotes inside: --script 'gs.log(\"Hello\")')" })
15
+ .option('script', { alias: 's', type: 'string', describe: 'JavaScript code to execute (single-quote the script, double quotes inside: --script \'gs.log("Hello")\')' })
10
16
  .option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' })
11
- .option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: global)' });
17
+ .option('stdin', { type: 'boolean', describe: 'Read script from stdin (pipe-friendly, e.g. cat script.js | jsn eval --stdin)' })
18
+ .option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: active scope from banner)' })
19
+ // Script-mode flags (issue #177) — map to the Script Background form checkboxes
20
+ .option('rollback', { type: 'boolean', default: true, describe: 'Record rollback context (--no-rollback to skip)' })
21
+ .option('sandbox', { type: 'boolean', default: false, describe: 'Sandbox mode (KittyScript evaluator, single expression only — no DB writes)' })
22
+ .option('scriptlet', { type: 'boolean', default: false, describe: 'Run as scriptlet with global server-side objects' })
23
+ .option('quota-managed-transaction', { type: 'boolean', default: true, describe: 'Managed transaction limits (--no-quota-managed-transaction to skip)' });
12
24
  },
13
25
  handler: wrap(async (argv, app) => {
14
26
  let script;
15
27
  let scopeSysId = null;
16
28
 
17
- if (argv.scope) {
18
- scopeSysId = await app.sdk.resolveScope(argv.scope);
29
+ // Resolve execution scope: explicit --scope wins; otherwise use the
30
+ // active scope from apps.current_app (same source as the banner);
31
+ // fall back to global only when no active scope exists.
32
+ let scope = argv.scope || '';
33
+ if (!scope) {
34
+ try {
35
+ const user = await getCurrentUser(app.sdk);
36
+ if (user && user.sys_id) {
37
+ const currentApp = await getCurrentApplication(app.sdk, user.sys_id);
38
+ if (currentApp && currentApp.scope && currentApp.scope !== 'global') {
39
+ scope = currentApp.scope;
40
+ }
41
+ }
42
+ } catch {
43
+ // ignore — fall back to global
44
+ }
45
+ }
46
+
47
+ if (scope) {
48
+ scopeSysId = await app.sdk.resolveScope(scope);
19
49
  if (!scopeSysId) {
20
- throw new Error(`Scope not found: ${argv.scope}`);
50
+ throw new Error(`Scope not found: ${scope}`);
21
51
  }
22
52
  }
23
53
 
24
54
  if (argv.file) {
25
55
  script = fs.readFileSync(argv.file, 'utf-8');
56
+ } else if (argv.stdin) {
57
+ script = await new Promise((resolve, reject) => {
58
+ let data = '';
59
+ process.stdin.setEncoding('utf-8');
60
+ process.stdin.on('data', (chunk) => { data += chunk; });
61
+ process.stdin.on('end', () => resolve(data));
62
+ process.stdin.on('error', reject);
63
+ });
26
64
  } else if (argv.script) {
27
65
  script = argv.script;
28
66
  } else {
29
- throw new Error('--script or --file is required');
67
+ throw new Error('--script, --file, or --stdin is required');
68
+ }
69
+
70
+ // Sandbox mode uses the KittyScript evaluator, which only accepts a
71
+ // single expression — multi-statement scripts fail server-side. Warn
72
+ // early, on the user's raw script (before the $scopeSysId prepend).
73
+ if (argv.sandbox) {
74
+ const statements = script.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('//'));
75
+ const multiLine = statements.length > 1;
76
+ const hasTerminator = /;[\s\S]*\S/.test(script);
77
+ if (multiLine || hasTerminator) {
78
+ process.stderr.write('Warning: --sandbox runs a single expression (KittyScript). Multi-statement scripts will fail.\n\n');
79
+ }
30
80
  }
31
81
 
32
82
  if (scopeSysId) {
@@ -36,10 +86,18 @@ export function evalCmd(wrap) {
36
86
  const warning = scopeSysId
37
87
  ? ` ⚠️ Records without explicit sys_scope will land in global. Use $scopeSysId variable.`
38
88
  : '';
39
- const output = await app.sdk.executeScript(script, argv.scope);
89
+ // Pass the resolved sys_id (not the name) — sys.scripts.do only
90
+ // honors selectable scope values, which are sys_ids.
91
+ const output = await app.sdk.executeScript(script, scopeSysId || '', {
92
+ rollback: argv.rollback,
93
+ quotaManagedTransaction: argv.quotaManagedTransaction,
94
+ sandbox: argv.sandbox,
95
+ scriptlet: argv.scriptlet,
96
+ });
40
97
  app.ok({
41
98
  script,
42
99
  output,
100
+ scope: scope || 'global',
43
101
  instance: app.getEffectiveInstance(),
44
102
  }, {
45
103
  summary: 'Script executed' + warning,
@@ -1,4 +1,7 @@
1
1
  import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
2
+ import { declareCapabilities } from '../../capabilities.js';
3
+
4
+ declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete'], devAlias: true });
2
5
 
3
6
  export function flowsCmd(wrap) {
4
7
  return {