@jacebenson/jsn 4.3.7-dev.41 → 4.3.7-dev.43
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 +8 -1
- package/src/commands/auth.js +21 -11
- package/src/commands/groupmembers.js +19 -4
- package/src/commands/grouproles.js +15 -4
- package/src/commands/perf.js +30 -9
- package/src/commands/skill.js +5 -2
- package/src/errors.js +17 -4
- package/src/flow-create.js +26 -4
- package/src/flow-inspection.js +15 -5
- package/src/flow-test.js +6 -6
- package/src/flow-triggers.js +53 -0
- package/src/helpers.js +109 -9
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { saveConfig } from './config.js';
|
|
|
14
14
|
import { resolveSession, applySession, contextFromSession, requireSessionInstance } from './session.js';
|
|
15
15
|
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
16
16
|
import { errUsage, errAuth } from './errors.js';
|
|
17
|
+
import { canPrompt } from './helpers.js';
|
|
17
18
|
import { isMutationCommand } from './mutations.js';
|
|
18
19
|
import process from 'node:process';
|
|
19
20
|
|
|
@@ -190,8 +191,14 @@ export class App {
|
|
|
190
191
|
this.output.err(error);
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* True only when a human can actually answer a prompt. Delegates to
|
|
196
|
+
* canPrompt() so the check is "both stdio descriptors are TTYs AND no
|
|
197
|
+
* agent/CI/opt-out marker is set" — a PTY allocated for an agent
|
|
198
|
+
* (stdout.isTTY true, JSN_NO_PROMPTS/CI set) is NOT interactive.
|
|
199
|
+
*/
|
|
193
200
|
isInteractive() {
|
|
194
|
-
return
|
|
201
|
+
return canPrompt();
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
requireInstance() {
|
package/src/commands/auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
|
|
2
|
-
import { errUsage } from '../errors.js';
|
|
2
|
+
import { errUsage, errUsageHint } from '../errors.js';
|
|
3
|
+
import { canUsePicker } from '../helpers.js';
|
|
3
4
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
5
|
import process from 'node:process';
|
|
5
6
|
|
|
@@ -128,14 +129,16 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
|
-
* Pick a profile by name — interactive search picker when ambiguous
|
|
132
|
-
* auto-selects when there's exactly one, throws when
|
|
132
|
+
* Pick a profile by name — interactive search picker when ambiguous and a
|
|
133
|
+
* human is available, auto-selects when there's exactly one, throws when
|
|
134
|
+
* none configured. A non-interactive caller (agent, pipe, --json/--get)
|
|
135
|
+
* never opens a picker; it falls back to the single/default profile.
|
|
133
136
|
*/
|
|
134
137
|
export async function pickProfile(app, message) {
|
|
135
138
|
const { search } = await import('@inquirer/prompts');
|
|
136
139
|
const profileNames = Object.keys(app.config.profiles || {});
|
|
137
140
|
if (profileNames.length === 0) throw errUsage('No profiles configured. Run "jsn setup" first.');
|
|
138
|
-
if (profileNames.length === 1 || !app
|
|
141
|
+
if (profileNames.length === 1 || !canUsePicker(app, {})) return profileNames[0];
|
|
139
142
|
const choices = profileNames.map(name => ({
|
|
140
143
|
name: `${name} — ${app.config.profiles[name].instance_url}`,
|
|
141
144
|
value: name,
|
|
@@ -281,10 +284,9 @@ export async function loginWizard(app, argv = {}) {
|
|
|
281
284
|
app.config.activeProfile = profileName;
|
|
282
285
|
app.config.defaultProfile = profileName;
|
|
283
286
|
|
|
284
|
-
// Present all connection-level options during setup. Gated on
|
|
285
|
-
// so a piped/scripted call never blocks on prompts.
|
|
286
|
-
|
|
287
|
-
if (canPrompt()) {
|
|
287
|
+
// Present all connection-level options during setup. Gated on
|
|
288
|
+
// interactivity so a piped/scripted call never blocks on prompts.
|
|
289
|
+
if (canUsePicker(app, argv)) {
|
|
288
290
|
// @inquirer/prompts owns stdin while these confirmations run. Close the
|
|
289
291
|
// older readline interface before handing stdin to Inquirer. This matters
|
|
290
292
|
// on Windows Git Bash: leaving both readers attached can make the later
|
|
@@ -604,7 +606,7 @@ export function authCmd(wrap) {
|
|
|
604
606
|
instanceURL = normalizeInstanceURL(namedProfile.instance_url);
|
|
605
607
|
} else if (argv.instance) {
|
|
606
608
|
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
607
|
-
} else if (app
|
|
609
|
+
} else if (canUsePicker(app, argv)) {
|
|
608
610
|
// gh-faithful: interactive terminals always get a picker —
|
|
609
611
|
// existing profiles plus "+ Add a new instance". Zero profiles
|
|
610
612
|
// goes straight to the setup wizard.
|
|
@@ -987,7 +989,9 @@ Examples:
|
|
|
987
989
|
if (profileNames.length === 0) {
|
|
988
990
|
throw errUsage('No profiles configured. Run "jsn auth login" first.');
|
|
989
991
|
}
|
|
990
|
-
if (profileNames.length
|
|
992
|
+
if (profileNames.length === 1) {
|
|
993
|
+
name = profileNames[0];
|
|
994
|
+
} else if (canUsePicker(app, argv)) {
|
|
991
995
|
const { search } = await import('@inquirer/prompts');
|
|
992
996
|
const choices = profileNames.map(profileName => ({
|
|
993
997
|
name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
|
|
@@ -1002,7 +1006,13 @@ Examples:
|
|
|
1002
1006
|
});
|
|
1003
1007
|
name = selected;
|
|
1004
1008
|
} else {
|
|
1005
|
-
|
|
1009
|
+
// Never silently switch the active profile for a
|
|
1010
|
+
// non-interactive caller: picking one for them is a
|
|
1011
|
+
// config write they did not ask for.
|
|
1012
|
+
throw errUsageHint(
|
|
1013
|
+
`Which profile? ${profileNames.length} profiles are configured: ${profileNames.join(', ')}`,
|
|
1014
|
+
'Pass the profile name: jsn auth switch <name>'
|
|
1015
|
+
);
|
|
1006
1016
|
}
|
|
1007
1017
|
}
|
|
1008
1018
|
await setActiveProfile(app.config, name);
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch, confirmDelete, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
3
|
+
|
|
4
|
+
// add/remove write sys_user_grmember; the mutation guard (instance +
|
|
5
|
+
// read-only profile) and the delete confirmation both key off this.
|
|
6
|
+
declareCapabilities('groupmembers', { mutationSubcommands: ['add', 'remove'] });
|
|
2
7
|
|
|
3
8
|
export function groupMembersCmd(wrap) {
|
|
4
9
|
return {
|
|
@@ -42,6 +47,8 @@ export function groupMembersCmd(wrap) {
|
|
|
42
47
|
handler: wrap(async (argv, app) => {
|
|
43
48
|
const groupName = argv.group;
|
|
44
49
|
const userName = argv.user;
|
|
50
|
+
assertSafeExactMatch(groupName);
|
|
51
|
+
assertSafeExactMatch(userName);
|
|
45
52
|
|
|
46
53
|
// Resolve group sys_id
|
|
47
54
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
|
@@ -86,7 +93,7 @@ export function groupMembersCmd(wrap) {
|
|
|
86
93
|
summary: `Added ${userName} to ${groupName}`,
|
|
87
94
|
breadcrumbs: [
|
|
88
95
|
{ action: 'list', cmd: `jsn groupmembers list --query "group.name=${groupName}"`, description: 'List group members' },
|
|
89
|
-
{ action: 'remove', cmd: `jsn groupmembers remove --group
|
|
96
|
+
{ action: 'remove', cmd: `jsn groupmembers remove --group ${quoteShellArg(groupName)} --user ${quoteShellArg(userName)}`, description: 'Remove this member' },
|
|
90
97
|
],
|
|
91
98
|
});
|
|
92
99
|
}),
|
|
@@ -96,10 +103,18 @@ export function groupMembersCmd(wrap) {
|
|
|
96
103
|
describe: 'Remove a user from a group',
|
|
97
104
|
builder: (y) => y
|
|
98
105
|
.option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
|
|
99
|
-
.option('user', { alias: 'u', type: 'string', demandOption: true, describe: 'Username or sys_id (required)' })
|
|
106
|
+
.option('user', { alias: 'u', type: 'string', demandOption: true, describe: 'Username or sys_id (required)' })
|
|
107
|
+
.option('force', { type: 'boolean', describe: 'Skip the confirmation prompt (removal)' }),
|
|
100
108
|
handler: wrap(async (argv, app) => {
|
|
101
109
|
const groupName = argv.group;
|
|
102
110
|
const userName = argv.user;
|
|
111
|
+
assertSafeExactMatch(groupName);
|
|
112
|
+
assertSafeExactMatch(userName);
|
|
113
|
+
// Confirm before any lookup: a refused confirmation must not
|
|
114
|
+
// touch the instance at all.
|
|
115
|
+
await confirmDelete(app, argv, `Remove ${userName} from group ${groupName}`, {
|
|
116
|
+
rerun: ['groupmembers', 'remove', '--group', groupName, '--user', userName],
|
|
117
|
+
});
|
|
103
118
|
|
|
104
119
|
// Resolve group sys_id
|
|
105
120
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
|
@@ -142,7 +157,7 @@ export function groupMembersCmd(wrap) {
|
|
|
142
157
|
summary: `Removed ${userName} from ${groupName}`,
|
|
143
158
|
breadcrumbs: [
|
|
144
159
|
{ action: 'list', cmd: `jsn groupmembers list --query "group.name=${groupName}"`, description: 'List group members' },
|
|
145
|
-
{ action: 'add', cmd: `jsn groupmembers add --group
|
|
160
|
+
{ action: 'add', cmd: `jsn groupmembers add --group ${quoteShellArg(groupName)} --user ${quoteShellArg(userName)}`, description: 'Add user back to group' },
|
|
146
161
|
],
|
|
147
162
|
});
|
|
148
163
|
}),
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch, confirmDelete, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
3
|
+
|
|
4
|
+
// add/remove write sys_group_has_role; the mutation guard (instance +
|
|
5
|
+
// read-only profile) and the delete confirmation both key off this.
|
|
6
|
+
declareCapabilities('grouproles', { mutationSubcommands: ['add', 'remove'] });
|
|
2
7
|
|
|
3
8
|
export function groupRolesCmd(wrap) {
|
|
4
9
|
return {
|
|
@@ -88,7 +93,7 @@ export function groupRolesCmd(wrap) {
|
|
|
88
93
|
summary: `Added role ${roleName} to ${groupName}`,
|
|
89
94
|
breadcrumbs: [
|
|
90
95
|
{ action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
|
|
91
|
-
{ action: 'remove', cmd: `jsn grouproles remove --group
|
|
96
|
+
{ action: 'remove', cmd: `jsn grouproles remove --group ${quoteShellArg(groupName)} --role ${quoteShellArg(roleName)}`, description: 'Remove this role' },
|
|
92
97
|
],
|
|
93
98
|
});
|
|
94
99
|
}),
|
|
@@ -98,12 +103,18 @@ export function groupRolesCmd(wrap) {
|
|
|
98
103
|
describe: 'Remove a role from a group',
|
|
99
104
|
builder: (y) => y
|
|
100
105
|
.option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
|
|
101
|
-
.option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' })
|
|
106
|
+
.option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' })
|
|
107
|
+
.option('force', { type: 'boolean', describe: 'Skip the confirmation prompt (removal)' }),
|
|
102
108
|
handler: wrap(async (argv, app) => {
|
|
103
109
|
const groupName = argv.group;
|
|
104
110
|
const roleName = argv.role;
|
|
105
111
|
assertSafeExactMatch(groupName);
|
|
106
112
|
assertSafeExactMatch(roleName);
|
|
113
|
+
// Confirm before any lookup: a refused confirmation must not
|
|
114
|
+
// touch the instance at all.
|
|
115
|
+
await confirmDelete(app, argv, `Remove role ${roleName} from group ${groupName}`, {
|
|
116
|
+
rerun: ['grouproles', 'remove', '--group', groupName, '--role', roleName],
|
|
117
|
+
});
|
|
107
118
|
|
|
108
119
|
// Resolve group sys_id
|
|
109
120
|
const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
|
|
@@ -146,7 +157,7 @@ export function groupRolesCmd(wrap) {
|
|
|
146
157
|
summary: `Removed role ${roleName} from ${groupName}`,
|
|
147
158
|
breadcrumbs: [
|
|
148
159
|
{ action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
|
|
149
|
-
{ action: 'add', cmd: `jsn grouproles add --group
|
|
160
|
+
{ action: 'add', cmd: `jsn grouproles add --group ${quoteShellArg(groupName)} --role ${quoteShellArg(roleName)}`, description: 'Add role back to group' },
|
|
150
161
|
],
|
|
151
162
|
});
|
|
152
163
|
}),
|
package/src/commands/perf.js
CHANGED
|
@@ -2,14 +2,21 @@ import { errUsageHint, errUsage } from '../errors.js';
|
|
|
2
2
|
import { captureRun, listRuns, getRun, compareRuns, formatRunDetailed, formatRunList, formatComparisonDetailed } from '../perf.js';
|
|
3
3
|
import { topTransactionsCmd } from './transactions.js';
|
|
4
4
|
import { paginatedSearch } from '../paginated-search.js';
|
|
5
|
+
import { canUsePicker } from '../helpers.js';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* A picker is only safe when a human is reachable AND the invocation did
|
|
9
|
+
* not ask for machine output (`--json`/`--quiet`) or a one-shot extraction
|
|
10
|
+
* (`--get`). canUsePicker() owns that rule so CI/agent markers and
|
|
11
|
+
* JSN_NO_PROMPTS are honored too.
|
|
12
|
+
*/
|
|
13
|
+
function pickerEnabled(app, argv = {}) {
|
|
14
|
+
return canUsePicker(app, argv);
|
|
8
15
|
}
|
|
9
16
|
|
|
10
|
-
async function pickRun(app, { exclude = new Set(), message = 'Select a performance capture' } = {}) {
|
|
17
|
+
async function pickRun(app, { exclude = new Set(), message = 'Select a performance capture', argv = {} } = {}) {
|
|
11
18
|
const runs = listRuns({ limit: 1000 }).filter(run => !exclude.has(run.run_id));
|
|
12
|
-
if (!pickerEnabled(app) || runs.length === 0) return null;
|
|
19
|
+
if (!pickerEnabled(app, argv) || runs.length === 0) return null;
|
|
13
20
|
const selected = await paginatedSearch({
|
|
14
21
|
message,
|
|
15
22
|
totalCount: runs.length,
|
|
@@ -78,7 +85,7 @@ export function perfCmd(wrap) {
|
|
|
78
85
|
builder: (b) => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum runs to show' }),
|
|
79
86
|
handler: wrap(async (argv, app) => {
|
|
80
87
|
const runs = listRuns({ limit: argv.limit });
|
|
81
|
-
const picked = await pickRun(app, { message: 'Select a performance capture' });
|
|
88
|
+
const picked = await pickRun(app, { message: 'Select a performance capture', argv });
|
|
82
89
|
if (picked) {
|
|
83
90
|
const run = getRun(picked.run_id);
|
|
84
91
|
app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
|
|
@@ -100,8 +107,16 @@ export function perfCmd(wrap) {
|
|
|
100
107
|
command: 'show [run_id]',
|
|
101
108
|
describe: 'Show one saved performance capture',
|
|
102
109
|
handler: wrap(async (argv, app) => {
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
// No run ID and no human to pick one: fail with a usable error
|
|
111
|
+
// instead of exiting 0 with no output at all.
|
|
112
|
+
if (!argv.run_id && !pickerEnabled(app, argv)) {
|
|
113
|
+
throw errUsageHint(
|
|
114
|
+
'Missing run ID: `jsn perf show` needs a run ID when prompts are unavailable.',
|
|
115
|
+
'Find a capture with `jsn perf list`, then run `jsn perf show <run_id>`.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const run = argv.run_id ? requireRun(argv.run_id, 'target') : await pickRun(app, { argv });
|
|
119
|
+
if (!run) return; // cancelled at the picker
|
|
105
120
|
app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
|
|
106
121
|
summary: `Performance capture ${run.run_id}`,
|
|
107
122
|
breadcrumbs: [listHint(), compareHint(run.run_id)],
|
|
@@ -118,9 +133,15 @@ export function perfCmd(wrap) {
|
|
|
118
133
|
describe: 'Compare exactly two saved captures',
|
|
119
134
|
handler: wrap(async (argv, app) => {
|
|
120
135
|
if (!argv.baseline && !argv.new) {
|
|
121
|
-
|
|
136
|
+
if (!pickerEnabled(app, argv)) {
|
|
137
|
+
throw errUsageHint(
|
|
138
|
+
'Compare requires exactly two run IDs when prompts are unavailable.',
|
|
139
|
+
'Run `jsn perf compare <baseline> <new>` with two run IDs from `jsn perf list`.'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const baseline = await pickRun(app, { message: 'Select the baseline capture', argv });
|
|
122
143
|
if (!baseline) return;
|
|
123
|
-
const newer = await pickRun(app, { exclude: new Set([baseline.run_id]), message: 'Select the new capture' });
|
|
144
|
+
const newer = await pickRun(app, { exclude: new Set([baseline.run_id]), message: 'Select the new capture', argv });
|
|
124
145
|
if (!newer) return;
|
|
125
146
|
const baselineRun = getRun(baseline.run_id);
|
|
126
147
|
const newerRun = getRun(newer.run_id);
|
package/src/commands/skill.js
CHANGED
|
@@ -8,6 +8,7 @@ import { execSync } from 'node:child_process';
|
|
|
8
8
|
|
|
9
9
|
import { globalConfigPath } from '../config.js';
|
|
10
10
|
import { declareCapabilities } from '../capabilities.js';
|
|
11
|
+
import { canUsePicker } from '../helpers.js';
|
|
11
12
|
|
|
12
13
|
declareCapabilities('skill', { noInstance: true, skipDailyChecks: true });
|
|
13
14
|
|
|
@@ -305,8 +306,10 @@ export function skillCmd(wrap) {
|
|
|
305
306
|
}
|
|
306
307
|
}
|
|
307
308
|
}
|
|
308
|
-
} else if (
|
|
309
|
-
// Interactive: show multi-select picker
|
|
309
|
+
} else if (canUsePicker(app, argv)) {
|
|
310
|
+
// Interactive: show multi-select picker. canUsePicker() (not a
|
|
311
|
+
// bare stdin TTY check) keeps a PTY allocated for an agent,
|
|
312
|
+
// CI, JSN_NO_PROMPTS, --json/--quiet or --get out of a prompt.
|
|
310
313
|
const fetchedVersion = extractVersion(content);
|
|
311
314
|
// Check if any existing installed copies are outdated
|
|
312
315
|
const outdated = [];
|
package/src/errors.js
CHANGED
|
@@ -140,14 +140,27 @@ export function errExtractionNotFound(path) {
|
|
|
140
140
|
/**
|
|
141
141
|
* Structured error for a destructive action that requires confirmation
|
|
142
142
|
* but no human is available to answer (non-TTY, pipes, or AI agents).
|
|
143
|
+
*
|
|
143
144
|
* The hint is written to be machine-parseable: an agent can read the
|
|
144
|
-
* re-run command
|
|
145
|
+
* exact re-run command — target, table, pinned profile/instance — and
|
|
146
|
+
* present it to its user for approval before executing. It deliberately
|
|
147
|
+
* does NOT recommend permanently disabling confirmations: an approval for
|
|
148
|
+
* one destructive action is not consent to disarm the guard for every
|
|
149
|
+
* future action.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} question — e.g. `Delete incident INC0010001?`
|
|
152
|
+
* @param {string} [rerunCmd] — complete re-run command WITHOUT --force,
|
|
153
|
+
* already shell-quoted. Empty when the caller cannot reconstruct it.
|
|
145
154
|
*/
|
|
146
|
-
export function errConfirmationRequired(question, rerunCmd) {
|
|
155
|
+
export function errConfirmationRequired(question, rerunCmd = '') {
|
|
156
|
+
const cmd = String(rerunCmd || '').trim();
|
|
157
|
+
const hint = cmd
|
|
158
|
+
? `This is a destructive action. Ask your user to approve this exact command, then re-run:\n ${cmd} --force`
|
|
159
|
+
: 'This is a destructive action. Ask your user to approve it, then re-run the same command with --force.';
|
|
147
160
|
return new AppError(
|
|
148
161
|
CodeConfirmationRequired,
|
|
149
|
-
`${question} — confirmation required. Pass --force
|
|
150
|
-
|
|
162
|
+
`${question} — confirmation required. Pass --force only after your user approves this operation.`,
|
|
163
|
+
hint
|
|
151
164
|
);
|
|
152
165
|
}
|
|
153
166
|
|
package/src/flow-create.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { assertSafeExactMatch } from './helpers.js';
|
|
3
3
|
import { createVerifiedFlowVersion } from './flow-versioning.js';
|
|
4
4
|
import {
|
|
5
|
+
RECORD_TRIGGER_FAMILY,
|
|
5
6
|
SUPPORTED_TRIGGER_TYPES,
|
|
6
7
|
TRIGGER_DEFINITION_TABLE,
|
|
7
8
|
buildTriggerInputs,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
readTriggerInputDefinitions,
|
|
12
13
|
tableInputFor,
|
|
13
14
|
flowDesignParameter,
|
|
15
|
+
triggerFamily,
|
|
14
16
|
} from './flow-triggers.js';
|
|
15
17
|
|
|
16
18
|
const FLOW_ENDPOINT = '/api/now/processflow/flow';
|
|
@@ -264,7 +266,11 @@ export function buildTypedInputs(action, schema) {
|
|
|
264
266
|
throw new Error(`Action ${action.type} input "${field.name}" has a malformed parameter block; refusing to change it`);
|
|
265
267
|
}
|
|
266
268
|
const suppliedValue = Object.prototype.hasOwnProperty.call(supplied, field.name) ? supplied[field.name] : field.default;
|
|
267
|
-
|
|
269
|
+
const parameter = field.parameter || {};
|
|
270
|
+
if ((suppliedValue === undefined || suppliedValue === null)
|
|
271
|
+
&& (field.mandatory === true || field.required === true || parameter.mandatory === true || parameter.required === true)) {
|
|
272
|
+
throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
|
|
273
|
+
}
|
|
268
274
|
const value = suppliedValue ?? null;
|
|
269
275
|
const displayValue = field.displayValue ?? field.display_value ?? value;
|
|
270
276
|
// global_snFlowDesigner_ActionInstanceValue accepts ONLY these keys. The
|
|
@@ -277,13 +283,29 @@ export function buildTypedInputs(action, schema) {
|
|
|
277
283
|
children: Array.isArray(field.children) ? field.children : [],
|
|
278
284
|
displayField: stringValue(field.displayField || field.display_field || ''),
|
|
279
285
|
scriptActive: false,
|
|
280
|
-
parameter:
|
|
286
|
+
parameter: actionInputParameter(field),
|
|
281
287
|
value: { schemaless: false, schemalessValue: '', value },
|
|
282
288
|
displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
|
|
283
289
|
};
|
|
284
290
|
});
|
|
285
291
|
}
|
|
286
292
|
|
|
293
|
+
// Providers hand back the input metadata in one of two shapes: nested inside a
|
|
294
|
+
// `parameter` block (the earlier contract) or FLAT on the input object (the real
|
|
295
|
+
// published Log action's action_types route). The nested block wins when it
|
|
296
|
+
// exists, flat keys fill in only what it leaves empty, and the result is always
|
|
297
|
+
// narrowed to the fields global_snFlowDesigner_FlowDesignParameter accepts.
|
|
298
|
+
export function actionInputParameter(field) {
|
|
299
|
+
if (field?.parameter === undefined) return flowDesignParameter(field);
|
|
300
|
+
const parameter = flowDesignParameter(field.parameter);
|
|
301
|
+
const flat = flowDesignParameter(field);
|
|
302
|
+
for (const key of Object.keys(flat)) {
|
|
303
|
+
if (flat[key] === undefined || flat[key] === '') continue;
|
|
304
|
+
if (parameter[key] === undefined || parameter[key] === '') parameter[key] = flat[key];
|
|
305
|
+
}
|
|
306
|
+
return parameter;
|
|
307
|
+
}
|
|
308
|
+
|
|
287
309
|
function parseSimpleCondition(condition) {
|
|
288
310
|
if (!condition) return null;
|
|
289
311
|
const match = condition.match(/^([A-Za-z_][A-Za-z0-9_.]*)(STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)([^,^]*)$/);
|
|
@@ -552,8 +574,8 @@ function updatedTrigger(flow, manifest, descriptor) {
|
|
|
552
574
|
if (!existing) throw new Error('Saved flow has no trigger instance');
|
|
553
575
|
const existingMetadata = parseMaybeJson(existing.metadata, null);
|
|
554
576
|
if (existing.metadata && (!existingMetadata || typeof existingMetadata !== 'object' || Array.isArray(existingMetadata))) throw new Error('Saved record trigger metadata is unreadable; refusing to replace it');
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
577
|
+
const existingFamily = triggerFamily(existing);
|
|
578
|
+
if (existingFamily && existingFamily !== RECORD_TRIGGER_FAMILY) throw new Error(`Only ${RECORD_TRIGGER_FAMILY} trigger updates are supported; found ${existingFamily}`);
|
|
557
579
|
const inputs = clone(inputList(existing));
|
|
558
580
|
const tableInput = inputs.find(input => input.name === 'table');
|
|
559
581
|
const conditionInput = inputs.find(input => input.name === 'condition');
|
package/src/flow-inspection.js
CHANGED
|
@@ -298,11 +298,7 @@ function buildLabelCache(payload) {
|
|
|
298
298
|
if (!payload || typeof payload !== 'object') return cache;
|
|
299
299
|
let entries = payload.label_cache;
|
|
300
300
|
if (!Array.isArray(entries)) {
|
|
301
|
-
|
|
302
|
-
entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
|
|
303
|
-
} catch {
|
|
304
|
-
entries = [];
|
|
305
|
-
}
|
|
301
|
+
entries = parseLabelCacheJson(payload.labelCacheAsJsonString);
|
|
306
302
|
}
|
|
307
303
|
for (const e of entries) {
|
|
308
304
|
if (!e || typeof e !== 'object') continue;
|
|
@@ -313,6 +309,20 @@ function buildLabelCache(payload) {
|
|
|
313
309
|
return cache;
|
|
314
310
|
}
|
|
315
311
|
|
|
312
|
+
// labelCacheAsJsonString is a JSON document, and JSON `null` or `{}` parses
|
|
313
|
+
// successfully into something that is not iterable — that threw
|
|
314
|
+
// "entries is not iterable" and broke `flows show` on real drafts whose cache is
|
|
315
|
+
// empty. An empty cache simply has no labels.
|
|
316
|
+
function parseLabelCacheJson(value) {
|
|
317
|
+
if (typeof value !== 'string' || !value.trim()) return [];
|
|
318
|
+
try {
|
|
319
|
+
const parsed = JSON.parse(value);
|
|
320
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
316
326
|
const GUID_PILL_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([^}]+)\}\}/g;
|
|
317
327
|
|
|
318
328
|
/**
|
package/src/flow-test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertSafeExactMatch } from './helpers.js';
|
|
2
|
+
import { RECORD_TRIGGER_FAMILY, triggerFamily } from './flow-triggers.js';
|
|
2
3
|
|
|
3
4
|
const FLOW_ENDPOINT = '/api/now/processflow/flow';
|
|
4
5
|
|
|
@@ -30,15 +31,14 @@ function inputValue(trigger, name) {
|
|
|
30
31
|
return valueOf(input?.value ?? input?.displayValue);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
function triggerType(trigger) {
|
|
34
|
-
return valueOf(trigger?.triggerType || trigger?.trigger_type || trigger?.type);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
export function deriveRecordTrigger(definition) {
|
|
38
35
|
const trigger = findTrigger(definition);
|
|
39
36
|
if (!trigger) throw new Error('Flow definition has no trigger instance');
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
// `type` is the family; `triggerType` is only the picker category ("Record").
|
|
38
|
+
// triggerFamily keeps a deliberate legacy fallback for saved instances that
|
|
39
|
+
// predate the split.
|
|
40
|
+
const type = triggerFamily(trigger);
|
|
41
|
+
if (type !== RECORD_TRIGGER_FAMILY) throw new Error(`Flow test supports only ${RECORD_TRIGGER_FAMILY} triggers; found ${type || 'unknown'}`);
|
|
42
42
|
const table = inputValue(trigger, 'table');
|
|
43
43
|
if (!table) throw new Error('Flow definition record trigger has no table');
|
|
44
44
|
return { trigger, type, table };
|
package/src/flow-triggers.js
CHANGED
|
@@ -358,6 +358,59 @@ export function buildTriggerInstance({ flowId, definition, triggerType = '', inp
|
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
export const RECORD_TRIGGER_FAMILY = 'record_create_or_update';
|
|
362
|
+
|
|
363
|
+
// A trigger instance carries the FAMILY and the picker CATEGORY in two different
|
|
364
|
+
// fields: `type` is the family Flow Designer executes ("record_create_or_update",
|
|
365
|
+
// "daily", …) and `triggerType` is the category display name ("Record",
|
|
366
|
+
// "Scheduled", …). Creation writes the family to `type` (resolveTriggerDefinition
|
|
367
|
+
// reads it from sys_hub_trigger_definition), and the live ProcessFlow read-back
|
|
368
|
+
// returns exactly that shape, so `type` is authoritative.
|
|
369
|
+
//
|
|
370
|
+
// Instances and fixtures that predate that split stored the family in
|
|
371
|
+
// `triggerType` (and sometimes inside the raw metadata block), so those stay as
|
|
372
|
+
// deliberate fallbacks — otherwise a real saved trigger reads as the category
|
|
373
|
+
// "Record" and every consumer rejects it.
|
|
374
|
+
export function triggerFamily(source) {
|
|
375
|
+
const metadata = parsedObject(source?.metadata);
|
|
376
|
+
const candidates = [
|
|
377
|
+
source?.type,
|
|
378
|
+
source?.trigger_type,
|
|
379
|
+
metadata?.type,
|
|
380
|
+
metadata?.trigger_type,
|
|
381
|
+
source?.triggerType,
|
|
382
|
+
metadata?.triggerType,
|
|
383
|
+
];
|
|
384
|
+
for (const candidate of candidates) {
|
|
385
|
+
const family = familyValue(candidate);
|
|
386
|
+
if (family) return family;
|
|
387
|
+
}
|
|
388
|
+
return '';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function parsedObject(value) {
|
|
392
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
393
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
394
|
+
try {
|
|
395
|
+
const parsed = JSON.parse(value);
|
|
396
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function familyValue(value) {
|
|
403
|
+
if (value === null || value === undefined) return '';
|
|
404
|
+
if (typeof value === 'object') {
|
|
405
|
+
for (const key of ['value', 'display_value', 'sys_id']) {
|
|
406
|
+
const nested = familyValue(value[key]);
|
|
407
|
+
if (nested) return nested;
|
|
408
|
+
}
|
|
409
|
+
return '';
|
|
410
|
+
}
|
|
411
|
+
return String(value).trim();
|
|
412
|
+
}
|
|
413
|
+
|
|
361
414
|
// The snFlowDesigner GraphQL input types take `attributes` as a String
|
|
362
415
|
// ("key=value,key=value") even though the ProcessFlow read path renders it as an
|
|
363
416
|
// object. Sending the object fails validation: "not a valid 'String'".
|
package/src/helpers.js
CHANGED
|
@@ -32,6 +32,29 @@ export function canPrompt() {
|
|
|
32
32
|
return isTTY(process.stdout) && isTTY(process.stdin);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* True when an interactive picker may be opened for THIS invocation.
|
|
37
|
+
*
|
|
38
|
+
* canPrompt() alone answers "is a human reachable" — it does not know that
|
|
39
|
+
* the caller asked for machine output (`--json`, `--quiet`, `--csv`, …) or
|
|
40
|
+
* a one-shot extraction (`--get`). Opening a picker in either case blocks a
|
|
41
|
+
* script and corrupts the requested output shape, so every picker gate
|
|
42
|
+
* routes through here.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} app — app instance (uses app.output when present)
|
|
45
|
+
* @param {object} [argv] — parsed args (checks argv.get)
|
|
46
|
+
* @returns {boolean} true when a picker may be shown
|
|
47
|
+
*/
|
|
48
|
+
export function canUsePicker(app, argv = {}) {
|
|
49
|
+
if (argv && argv.get) return false;
|
|
50
|
+
if (!canPrompt()) return false;
|
|
51
|
+
const writer = app?.output;
|
|
52
|
+
if (!writer) return true; // no output controller (unit-test app) — canPrompt decided
|
|
53
|
+
const format = typeof writer.getFormat === 'function' ? writer.getFormat() : undefined;
|
|
54
|
+
if (!format) return true;
|
|
55
|
+
return format === FormatAuto;
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
/**
|
|
36
59
|
* Render a parent command's subcommand list for its default handler.
|
|
37
60
|
*
|
|
@@ -47,6 +70,55 @@ export function formatCommandList({ title, commands, footer }) {
|
|
|
47
70
|
return `${lines.join('\n')}\n`;
|
|
48
71
|
}
|
|
49
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Quote one argv token so the emitted confirmation hint is copy-pasteable
|
|
75
|
+
* in a POSIX shell. Boring tokens stay bare; anything containing spaces,
|
|
76
|
+
* quotes, `$`, `;`, `&`, `*` … gets single-quoted with embedded quotes
|
|
77
|
+
* escaped. `^ = < > ~ ! ,` are ServiceNow query characters — common in
|
|
78
|
+
* encoded queries — and are shell-safe bare, so they stay bare.
|
|
79
|
+
*
|
|
80
|
+
* @param {*} value — token
|
|
81
|
+
* @returns {string} shell-safe token
|
|
82
|
+
*/
|
|
83
|
+
export function quoteShellArg(value) {
|
|
84
|
+
const s = value == null ? '' : String(value);
|
|
85
|
+
if (s === '') return "''";
|
|
86
|
+
if (/^[A-Za-z0-9_@%+=:,./^-]+$/.test(s)) return s;
|
|
87
|
+
return `'${s.split("'").join("'\\''")}'`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the exact, safely-quoted rerun command for a confirmation hint.
|
|
92
|
+
*
|
|
93
|
+
* `argv._` holds command PATH NAMES only — the local adapter stores
|
|
94
|
+
* positionals and flags in named fields — so a hint built from `argv._`
|
|
95
|
+
* alone loses the target (`jsn incidents delete` with no number). Callers
|
|
96
|
+
* therefore pass the complete target tokens.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} argv — parsed args (reads `_`, `profile`, `instance`)
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {string|string[]} [opts.rerun] — complete rerun command: a token
|
|
101
|
+
* array (quoted here) or a full command string (used verbatim)
|
|
102
|
+
* @returns {string} command without `--force`, or '' when unknowable
|
|
103
|
+
*/
|
|
104
|
+
export function buildRerunCommand(argv = {}, { rerun = null } = {}) {
|
|
105
|
+
let parts;
|
|
106
|
+
if (Array.isArray(rerun) && rerun.length > 0) {
|
|
107
|
+
parts = ['jsn', ...rerun].map(quoteShellArg);
|
|
108
|
+
} else if (typeof rerun === 'string' && rerun.trim()) {
|
|
109
|
+
parts = [rerun.trim()];
|
|
110
|
+
} else {
|
|
111
|
+
parts = ['jsn', ...(argv?._ || [])].map(quoteShellArg);
|
|
112
|
+
}
|
|
113
|
+
if (parts.length === 1) return ''; // no path known — nothing worth repeating
|
|
114
|
+
const joined = parts.join(' ');
|
|
115
|
+
// Re-pin the profile/instance the caller was operating on, so following
|
|
116
|
+
// the hint cannot silently retarget a different instance or profile.
|
|
117
|
+
if (argv?.profile && !joined.includes('--profile')) parts.push('--profile', quoteShellArg(argv.profile));
|
|
118
|
+
if (argv?.instance && !joined.includes('--instance')) parts.push('--instance', quoteShellArg(argv.instance));
|
|
119
|
+
return parts.join(' ');
|
|
120
|
+
}
|
|
121
|
+
|
|
50
122
|
/**
|
|
51
123
|
* Confirm a destructive action before executing.
|
|
52
124
|
*
|
|
@@ -59,10 +131,12 @@ export function formatCommandList({ title, commands, footer }) {
|
|
|
59
131
|
* @param {object} app — app instance (reads active profile from app.config)
|
|
60
132
|
* @param {object} argv — parsed args (checks argv.force)
|
|
61
133
|
* @param {string} question — e.g. `Delete incident INC0010001?`
|
|
134
|
+
* @param {object} [options] — { rerun } complete rerun command or token
|
|
135
|
+
* array for the hint (see buildRerunCommand)
|
|
62
136
|
* @returns {Promise<boolean>} true when the action may proceed
|
|
63
137
|
* @throws when confirmation is required but denied/unavailable
|
|
64
138
|
*/
|
|
65
|
-
export async function confirmDelete(app, argv, question) {
|
|
139
|
+
export async function confirmDelete(app, argv, question, options = {}) {
|
|
66
140
|
const profile = getActiveProfile(app.config);
|
|
67
141
|
if (profile?.skip_confirmations === true) return true;
|
|
68
142
|
if (argv.force) return true;
|
|
@@ -80,8 +154,7 @@ export async function confirmDelete(app, argv, question) {
|
|
|
80
154
|
}
|
|
81
155
|
// No human available (pipes, scripts, AI agents). Fail fast with a
|
|
82
156
|
// structured error an agent can act on — never hang waiting for stdin.
|
|
83
|
-
|
|
84
|
-
throw errConfirmationRequired(question, cmd);
|
|
157
|
+
throw errConfirmationRequired(question, buildRerunCommand(argv, options));
|
|
85
158
|
}
|
|
86
159
|
|
|
87
160
|
|
|
@@ -298,14 +371,39 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
298
371
|
const pickerFields = pickerColumns.join(',');
|
|
299
372
|
const baseQuery = query ? query + '^' : '';
|
|
300
373
|
|
|
301
|
-
// Get total count
|
|
302
|
-
|
|
374
|
+
// Get total count. A FAILED count is unknown — never zero. Mapping the
|
|
375
|
+
// failure to 0 used to make callers report an invented "empty table"
|
|
376
|
+
// success (or silently return) when the real cause was a 403/network
|
|
377
|
+
// error. Only a measured 0 means empty; otherwise confirm emptiness with
|
|
378
|
+
// one bounded list request before showing the picker.
|
|
379
|
+
let totalCount = null;
|
|
380
|
+
let countError = null;
|
|
303
381
|
try {
|
|
304
382
|
totalCount = await app.sdk.aggregateCount(table, query || '');
|
|
305
|
-
} catch {
|
|
306
|
-
|
|
383
|
+
} catch (err) {
|
|
384
|
+
countError = err;
|
|
385
|
+
}
|
|
386
|
+
if (totalCount === 0) return null; // measured empty
|
|
387
|
+
|
|
388
|
+
if (countError) {
|
|
389
|
+
try {
|
|
390
|
+
const probeParams = new URLSearchParams();
|
|
391
|
+
probeParams.set('sysparm_limit', '1');
|
|
392
|
+
probeParams.set('sysparm_display_value', 'all');
|
|
393
|
+
probeParams.set('sysparm_fields', 'sys_id');
|
|
394
|
+
if (query) probeParams.set('sysparm_query', query);
|
|
395
|
+
const probe = await app.sdk.list(table, probeParams);
|
|
396
|
+
if (!Array.isArray(probe)) throw new Error('list returned a non-array result');
|
|
397
|
+
if (probe.length === 0) return null; // the list itself confirms empty
|
|
398
|
+
} catch (err) {
|
|
399
|
+
// Both the count and the list failed — surface the failure instead of
|
|
400
|
+
// reporting an empty result nobody measured.
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Could not list ${table}: ${err.message || err} (aggregate count also failed: ${countError.message || countError})`,
|
|
403
|
+
{ cause: err }
|
|
404
|
+
);
|
|
405
|
+
}
|
|
307
406
|
}
|
|
308
|
-
if (totalCount === 0) return null;
|
|
309
407
|
|
|
310
408
|
// Paginated source adapter: (term, offset, { signal }) => choices[]
|
|
311
409
|
async function serverSource(term, offset, { signal: _signal } = {}) {
|
|
@@ -418,7 +516,9 @@ export function parseDataArg(argv) {
|
|
|
418
516
|
try {
|
|
419
517
|
return JSON.parse(raw);
|
|
420
518
|
} catch (e) {
|
|
421
|
-
|
|
519
|
+
// Never echo the raw payload: real --data bodies carry personal data and
|
|
520
|
+
// secrets, and the parse position from e.message is enough to debug.
|
|
521
|
+
throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.`, { cause: e });
|
|
422
522
|
}
|
|
423
523
|
}
|
|
424
524
|
|