@jacebenson/jsn 4.3.7 → 4.3.8-dev.47

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 (41) hide show
  1. package/README.md +266 -1
  2. package/package.json +1 -1
  3. package/skills/servicenow/SKILL.md +16 -0
  4. package/src/app.js +8 -1
  5. package/src/auth.js +14 -12
  6. package/src/cli-adapter.js +162 -11
  7. package/src/cli.js +121 -13
  8. package/src/commands/_generic.js +3 -2
  9. package/src/commands/_simple.js +1 -1
  10. package/src/commands/actions.js +156 -0
  11. package/src/commands/auth.js +21 -11
  12. package/src/commands/cmdb.js +18 -0
  13. package/src/commands/diff.js +205 -0
  14. package/src/commands/docs/docs.js +18 -16
  15. package/src/commands/flows.js +149 -47
  16. package/src/commands/graphql.js +95 -0
  17. package/src/commands/groupmembers.js +19 -4
  18. package/src/commands/grouproles.js +15 -4
  19. package/src/commands/midservers.js +708 -0
  20. package/src/commands/perf.js +36 -9
  21. package/src/commands/platform.js +202 -0
  22. package/src/commands/records.js +19 -15
  23. package/src/commands/skill.js +5 -2
  24. package/src/commands/transactions.js +78 -0
  25. package/src/commands/updatesets.js +77 -11
  26. package/src/errors.js +71 -6
  27. package/src/flow-create.js +884 -0
  28. package/src/flow-designer-interactive.js +417 -0
  29. package/src/flow-inspection.js +15 -5
  30. package/src/flow-publish.js +32 -0
  31. package/src/flow-test.js +122 -0
  32. package/src/flow-triggers.js +448 -0
  33. package/src/flow-versioning.js +53 -0
  34. package/src/help.js +93 -28
  35. package/src/helpers.js +124 -9
  36. package/src/midserver-direct.js +312 -0
  37. package/src/midserver-install.js +170 -0
  38. package/src/midserver-zip.js +107 -0
  39. package/src/output.js +7 -0
  40. package/src/processflow-actions.js +366 -0
  41. package/src/sdk.js +8 -0
@@ -10,7 +10,11 @@ class CommandNode {
10
10
  constructor(definition) {
11
11
  this.definition = typeof definition === 'string' ? { command: definition } : definition;
12
12
  this.name = commandName(this.definition.command || '');
13
- this.aliases = names(this.definition.aliases || this.definition.alias);
13
+ // A command that lists its canonical name as its own alias publishes one
14
+ // token twice: the registry then has two entries for it and dispatch can
15
+ // resolve it through the alias table instead of the command table. An alias
16
+ // is an ALTERNATE name, so a self-alias is dropped at the source.
17
+ this.aliases = names(this.definition.aliases || this.definition.alias).filter((alias) => alias !== this.name);
14
18
  this.children = [];
15
19
  this.options = new Map();
16
20
  this.positionals = new Map();
@@ -24,11 +28,56 @@ class CommandNode {
24
28
  if (typeof this.definition.builder === 'function') this.definition.builder(this.api());
25
29
  }
26
30
 
31
+ /**
32
+ * Register a child, replacing any existing registration that claims the same
33
+ * canonical name (or that the new child claims as an alias).
34
+ *
35
+ * yargs semantics are last-registration-wins: a command module re-registers a
36
+ * subcommand to layer a specialized handler over the base one (see
37
+ * `requests show`). Appending instead of replacing left the base registration
38
+ * first in the list and reachable by dispatch/help while the duplicate was
39
+ * invisible to the registry (child counts, inventory, completion).
40
+ *
41
+ * Registration order is preserved (the slot is replaced in place) and the
42
+ * replaced node's aliases are carried over so layered commands keep resolving
43
+ * through the alias that the base registration published.
44
+ *
45
+ * A token must resolve to exactly ONE command. Two registrations can also
46
+ * collide without sharing a canonical name: an alias that another command
47
+ * already published (or already claims as its alias) is the same kind of
48
+ * claim, and registration order decides it — the later registration owns the
49
+ * token and the earlier one stops advertising it. The earlier command keeps
50
+ * its canonical name, so nothing becomes unreachable.
51
+ */
52
+ register(child) {
53
+ const index = this.children.findIndex((existing) => existing.name === child.name
54
+ || existing.aliases.includes(child.name)
55
+ || child.aliases.includes(existing.name));
56
+ if (index === -1) {
57
+ for (const sibling of this.children) {
58
+ sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
59
+ }
60
+ this.children.push(child);
61
+ return child;
62
+ }
63
+ // Carry the replaced node's aliases over — minus any alias that is now the
64
+ // replacement's own canonical name, which would advertise the command as
65
+ // its own alias.
66
+ child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])]
67
+ .filter((alias) => alias !== child.name);
68
+ for (const sibling of this.children) {
69
+ if (sibling === this.children[index]) continue;
70
+ sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
71
+ }
72
+ this.children[index] = child;
73
+ return child;
74
+ }
75
+
27
76
  api() {
28
77
  const node = this;
29
78
  return {
30
79
  command(definition, builder, handler) {
31
- node.children.push(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
80
+ node.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
32
81
  return this;
33
82
  },
34
83
  option(name, config = {}) { node.options.set(name, { ...config, name }); return this; },
@@ -63,10 +112,16 @@ export class LocalCLI {
63
112
  option(name, config) { this.root.options.set(name, { ...config, name }); return this; }
64
113
  options(config) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; }
65
114
  command(definition, builder, handler) {
66
- const node = new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition);
67
- this.root.children.push(node);
115
+ this.root.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
68
116
  return this;
69
117
  }
118
+ /**
119
+ * Register the fallback used when a parent command (a node with children) is
120
+ * invoked with no subcommand and declares no handler of its own. Without it
121
+ * those parents exited 0 having printed nothing, which reads as success to a
122
+ * script and as a hang to a human.
123
+ */
124
+ onParentDiscovery(fn) { this.parentDiscovery = fn; return this; }
70
125
  demandCommand(count, message) { this.root.demand = { count, message }; return this; }
71
126
  completion(name, _description, handler) { this.completionCommand = name; this.completionHandler = handler; return this; }
72
127
  help() { this.helpEnabled = true; return this; }
@@ -124,14 +179,22 @@ export class LocalCLI {
124
179
  return options.get(key) || [...options.values()].find((spec) => spec.name === key || names(spec.alias).includes(key))
125
180
  || { name: key, type: typeof value === 'boolean' ? 'boolean' : 'string' };
126
181
  }
127
- _assign(argv, key, value, spec) {
182
+ /**
183
+ * Assign one parsed value to argv.
184
+ *
185
+ * `validate: false` (tolerant mode, used by parseGlobalOptions) skips the
186
+ * choice/number checks so a value that will be thrown away anyway cannot
187
+ * abort a parse whose only purpose is reading the global output flags.
188
+ */
189
+ _assign(argv, key, value, spec, opts = {}) {
190
+ const validate = opts.validate !== false;
128
191
  const actual = spec?.name || key;
129
192
  const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
130
- if (spec?.choices && values.some((item) => !spec.choices.includes(item))) {
193
+ if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
131
194
  this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
132
195
  return false;
133
196
  }
134
- if (spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
197
+ if (validate && spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
135
198
  this._fail(`Invalid number for --${actual}: ${value}`);
136
199
  return false;
137
200
  }
@@ -143,7 +206,7 @@ export class LocalCLI {
143
206
  argv[camel(actual)] = argv[actual];
144
207
  for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
145
208
  }
146
- _parseOptions(args, path) {
209
+ _parseOptions(args, path, opts = {}) {
147
210
  const options = this._optionConfig(path);
148
211
  const defs = {};
149
212
  for (const [key, spec] of options) defs[key] = {
@@ -166,15 +229,92 @@ export class LocalCLI {
166
229
  const argv = { _: path.map((n) => n.name) };
167
230
  for (const [key, value] of Object.entries(parsed.values)) {
168
231
  const spec = this._specFor(options, key, value);
169
- if (this._assign(argv, key, value, spec) === false) return null;
232
+ if (this._assign(argv, key, value, spec, opts) === false) return null;
170
233
  }
171
- for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec);
234
+ for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec, opts);
172
235
  return { values: argv, positionals: parsed.positionals };
173
236
  }
237
+
238
+ /**
239
+ * Resolve the global options from raw tokens WITHOUT dispatching a command and
240
+ * without failing.
241
+ *
242
+ * The format middleware never runs when parsing fails, so the parse-time
243
+ * failure renderer has to know the format the caller asked for. Rather than
244
+ * re-scanning argv with a second, approximate tokenizer (any `--json`
245
+ * anywhere, first `--format` wins, no option termination, no value
246
+ * consumption, no `--no-` negation), it asks the SAME parser the successful
247
+ * path uses. Tolerant mode keeps a value error (`--format xml`, the very
248
+ * failure being rendered) from aborting the resolution.
249
+ *
250
+ * Returns null when the tokens cannot be parsed at all — the caller then
251
+ * falls back to the pipe/TTY default, exactly as it did before.
252
+ */
253
+ parseGlobalOptions(args) {
254
+ const saved = this.failHandler;
255
+ this.failHandler = null; // errors here are not the failure being reported
256
+ try {
257
+ const { path, commandArgs } = this._findPath(args);
258
+ const parsed = this._parseOptions(commandArgs, path, { validate: false });
259
+ return parsed ? parsed.values : null;
260
+ } catch {
261
+ return null;
262
+ } finally {
263
+ this.failHandler = saved;
264
+ }
265
+ }
174
266
  _fail(message, error) {
175
267
  if (this.failHandler) return this.failHandler(message, error);
176
268
  throw error || new Error(message);
177
269
  }
270
+ /**
271
+ * Describe the registered command surface: root commands (canonical name,
272
+ * aliases, describe, child names) and the global option declarations.
273
+ *
274
+ * This is the single source both the grouped root help and the machine
275
+ * discovery output are generated from, so neither can drift away from the
276
+ * commands that actually exist. Builders run here (idempotently) because a
277
+ * parent's children are only registered when its builder is invoked.
278
+ */
279
+ commandRegistry() {
280
+ const visible = (node) => node.name !== this.completionCommand;
281
+ const serialize = (node) => {
282
+ node.build();
283
+ return {
284
+ name: node.name,
285
+ command: node.definition.command || node.name,
286
+ aliases: [...node.aliases],
287
+ description: node.definition.describe || '',
288
+ subcommands: node.children.filter(visible).map((child) => child.name),
289
+ };
290
+ };
291
+ return {
292
+ commands: this.root.children.filter(visible).map(serialize),
293
+ options: [...this.root.options.values()].map((spec) => ({
294
+ name: spec.name,
295
+ alias: names(spec.alias),
296
+ description: spec.description || spec.describe || '',
297
+ choices: spec.choices || [],
298
+ type: spec.type || 'boolean',
299
+ })),
300
+ };
301
+ }
302
+
303
+ /** Structured child signatures for a parent node, built on demand. */
304
+ subcommandsOf(node) {
305
+ return node.children
306
+ .filter((child) => child.name !== this.completionCommand)
307
+ .map((child) => {
308
+ child.build();
309
+ return {
310
+ name: child.name,
311
+ command: child.definition.command || child.name,
312
+ aliases: [...child.aliases],
313
+ description: child.definition.describe || '',
314
+ };
315
+ });
316
+ }
317
+
178
318
  _help(node, path = []) {
179
319
  const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
180
320
  if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
@@ -232,6 +372,11 @@ export class LocalCLI {
232
372
  const parsed = this._parseOptions(commandArgs, path);
233
373
  if (!parsed) return;
234
374
  const argv = parsed.values;
375
+ // A parent command's own children are registry-derived, so a default
376
+ // handler can list exactly what is registered instead of repeating a
377
+ // hand-written list that drifts (see the records parent, which advertised
378
+ // an `inspect` description the registration disagreed with).
379
+ if (node.children.length) argv.subcommands = this.subcommandsOf(node);
235
380
  const specs = [...this._optionConfig(path).values()];
236
381
  const positions = positionSpec(node.definition.command || '').map((position) => ({
237
382
  ...position, ...node.positionals.get(position.name), name: position.name,
@@ -242,7 +387,13 @@ export class LocalCLI {
242
387
  for (const middleware of this.middlewares) await middleware(argv);
243
388
  for (const commandNode of path) for (const middleware of commandNode.middlewares) await middleware(argv);
244
389
  if (node.definition.handler) await node.definition.handler(argv);
245
- else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
390
+ // A parent with no handler of its own used to fall through and exit 0
391
+ // with no output — indistinguishable from a command that ran and found
392
+ // nothing. The registered discovery hook (cli.js) prints the child
393
+ // surface instead: a JSON envelope in machine mode, a list for humans.
394
+ else if (path.length && argv.subcommands?.length && this.parentDiscovery) {
395
+ await this.parentDiscovery(argv, { path: path.map((n) => n.name), subcommands: argv.subcommands });
396
+ } else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
246
397
  }
247
398
  }
248
399
 
package/src/cli.js CHANGED
@@ -10,7 +10,8 @@ import { resolveSession, applySession } from './session.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand, refreshMutationCommands } from './mutations.js';
12
12
  import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
13
- import { guardExit, exitWithError } from './errors.js';
13
+ import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
14
+ import { formatCommandList } from './helpers.js';
14
15
 
15
16
  // Command modules
16
17
  import { setupCmd } from './commands/setup.js';
@@ -69,6 +70,41 @@ import { b4rulesCmd } from './commands/b4rules.js';
69
70
  import { atfCmd } from './commands/atf.js';
70
71
  import { approvalsCmd } from './commands/approvals.js';
71
72
  import { perfCmd } from './commands/perf.js';
73
+ import { midserversCmd } from './commands/midservers.js';
74
+ import { diffCmd } from './commands/diff.js';
75
+ import { graphqlCmd } from './commands/graphql.js';
76
+
77
+ /**
78
+ * The output format the caller asked for, resolved from PARSED argv.
79
+ *
80
+ * One policy, two callers: the format middleware applies it once a command
81
+ * parses, and the parse-time failure handler applies it when parsing fails.
82
+ * They used to disagree — the failure path re-scanned the raw tokens (any
83
+ * `--json` anywhere, first `--format` match, no option termination, no values
84
+ * consumed by string options, no `--no-` negation), so the stream and the shape
85
+ * of a failure depended on which layer rejected the command instead of on the
86
+ * command line the caller typed.
87
+ *
88
+ * Precedence is the middleware's, unchanged: the shortcut flags beat `--format`,
89
+ * and among the shortcuts the one applied last wins
90
+ * (csv > markdown > styled > quiet > json).
91
+ */
92
+ export function resolveRequestedFormat(argv) {
93
+ if (!argv) return '';
94
+ if (argv.csv) return 'csv';
95
+ if (argv.markdown) return 'markdown';
96
+ if (argv.styled) return 'styled';
97
+ if (argv.quiet) return 'quiet';
98
+ if (argv.json) return 'json';
99
+ return typeof argv.format === 'string' ? argv.format : '';
100
+ }
101
+
102
+ /**
103
+ * Parent commands owned by another change. The adapter's discovery fallback
104
+ * (which gives a handler-less parent its child list instead of a silent exit 0)
105
+ * is skipped for these families so their own fix owns the behavior.
106
+ */
107
+ const DISCOVERY_OWNED_ELSEWHERE = new Set(['auth', 'perf', 'skill', 'groupmembers', 'grouproles']);
72
108
 
73
109
  function wrap(handler) {
74
110
  return async (argv) => {
@@ -148,15 +184,14 @@ export function buildCLI() {
148
184
  },
149
185
  })
150
186
  .middleware([
151
- // Apply format option overrides
187
+ // Apply format option overrides. The resolution lives in
188
+ // resolveRequestedFormat so the parse-time failure path applies the
189
+ // identical policy instead of approximating it from raw tokens.
152
190
  (argv) => {
153
- if (argv.json) argv.format = 'json';
154
- if (argv.quiet) argv.format = 'quiet';
155
- if (argv.styled) argv.format = 'styled';
156
- if (argv.markdown) argv.format = 'markdown';
157
- if (argv.csv) argv.format = 'csv';
158
- if (argv.format) {
159
- app.output.setFormat(argv.format);
191
+ const requested = resolveRequestedFormat(argv);
192
+ if (requested) {
193
+ argv.format = requested;
194
+ app.output.setFormat(requested);
160
195
  }
161
196
  if (argv.get) {
162
197
  app.output.setJqFilter(argv.get);
@@ -371,6 +406,9 @@ export function buildCLI() {
371
406
  .command(atfCmd(wrap))
372
407
  .command(approvalsCmd(wrap))
373
408
  .command(perfCmd(wrap))
409
+ .command(midserversCmd(wrap))
410
+ .command(diffCmd(wrap))
411
+ .command(graphqlCmd(wrap))
374
412
  .command(evalCmd(wrap))
375
413
  .command(restCmd(wrap))
376
414
  .command(skillCmd(wrap))
@@ -429,14 +467,48 @@ export function buildCLI() {
429
467
  cliInstance.showCompletionScript();
430
468
  },
431
469
  })
470
+ .onParentDiscovery((argv, { path, subcommands }) => {
471
+ // Reached only for a parent command that declares no default handler of
472
+ // its own (records attachments, docs community, and any future parent) —
473
+ // the adapter used to fall through and exit 0 having printed nothing.
474
+ // Children come from the registry, description included, so this list
475
+ // cannot drift from the registrations the way a hand-written one did.
476
+ if (DISCOVERY_OWNED_ELSEWHERE.has(path[0])) return;
477
+ const canonical = path.join(' ');
478
+ const summary = `jsn ${canonical} — available subcommands.`;
479
+ // Same error boundary as every registered handler (AGENTS.md: handlers go
480
+ // through wrap()). Discovery runs outside any command handler, and the
481
+ // shared extractor throws on a `--get` path that does not resolve — an
482
+ // unwrapped callback rejected parse() into an entry point with no
483
+ // rejection handler, so a missing path printed a Node stack instead of the
484
+ // extraction_not_found envelope.
485
+ return wrap(async () => {
486
+ (argv.app || app).ok({
487
+ command: canonical,
488
+ subcommands,
489
+ _formatted: formatCommandList({
490
+ title: `jsn ${canonical}`,
491
+ commands: subcommands,
492
+ footer: `Run "jsn ${canonical} <command> --help" for details.`,
493
+ }),
494
+ }, { summary });
495
+ })(argv);
496
+ })
432
497
  .help('help', 'Show help')
433
498
  .version(false)
434
499
  .strictCommands()
435
500
  .strictOptions(false)
436
501
  .fail((msg, err) => {
437
502
  if (err) throw err;
503
+ const raw = process.argv.slice(2);
504
+ // A parse-time failure never reaches the format middleware, so resolve the
505
+ // requested format with the adapter's OWN tokenizer (same option specs,
506
+ // option termination, consumed values, repeated options, `--no-`
507
+ // negation) instead of re-scanning raw argv — one policy, one answer for
508
+ // this command line no matter which layer rejects it.
509
+ const formatArgv = cliInstance.parseGlobalOptions(raw);
510
+ const requestedFormat = resolveRequestedFormat(formatArgv);
438
511
  if (msg === 'You must specify a command') {
439
- const raw = process.argv.slice(2);
440
512
  const hasLoneProfile = raw.some((a, i, arr) => {
441
513
  if (a !== '--profile' && a !== '-p') return false;
442
514
  const next = arr[i + 1];
@@ -452,11 +524,47 @@ export function buildCLI() {
452
524
  }
453
525
  process.exit(0);
454
526
  }
455
- process.stdout.write(renderHelp(loadConfig()) + '\n');
527
+ // No command is a discovery request, not a failure: the human view
528
+ // stays exactly as it was (exit 0 + grouped help), and an explicit
529
+ // --json gets the same surface as machine-readable data instead of
530
+ // prose that a parser cannot read.
531
+ if (requestedFormat === 'json') {
532
+ app.output.setFormat('json');
533
+ // `--get` is jsn's jq substitute for every JSON shape (AGENTS.md), so
534
+ // the root discovery envelope honours it too: extract when the path
535
+ // resolves, and fail with the structured envelope when it does not,
536
+ // instead of always dumping the whole envelope.
537
+ if (typeof formatArgv?.get === 'string' && formatArgv.get) {
538
+ app.output.setJqFilter(formatArgv.get);
539
+ }
540
+ const { commands } = cliInstance.commandRegistry();
541
+ try {
542
+ app.ok({ commands }, {
543
+ summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
544
+ });
545
+ } catch (discoveryError) {
546
+ exitWithError(discoveryError, { app });
547
+ }
548
+ process.exit(0);
549
+ }
550
+ process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
456
551
  process.exit(0);
457
552
  }
458
- process.stderr.write(msg + '\n');
459
- process.exit(1);
553
+ // Every other failure here (unknown command, missing required argument,
554
+ // invalid choice/number) is a usage failure. Route it through the same
555
+ // renderer as handler and guard failures: exit 2, and the documented
556
+ // error envelope on stdout when the caller is in JSON mode. Previously
557
+ // this wrote raw prose to stderr and exited 1, so the exit code and the
558
+ // output shape depended on which layer rejected the command.
559
+ if (requestedFormat) app.output.setFormat(requestedFormat);
560
+ let hint;
561
+ try {
562
+ const path = cliInstance._findPath(raw).path.map((n) => n.name).join(' ');
563
+ hint = `Run "jsn ${path ? `${path} ` : ''}--help" for usage.`;
564
+ } catch {
565
+ hint = 'Run "jsn --help" for the command list.';
566
+ }
567
+ exitWithError(new AppError(CodeUsage, msg, hint), { app });
460
568
  });
461
569
 
462
570
  // Snapshot the root command names (plus aliases) for the completion
@@ -181,11 +181,12 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
181
181
  }),
182
182
  })
183
183
  .command({
184
- command: 'show <identifier>',
184
+ command: opts.resolveIdentifier ? 'show [identifier]' : 'show <identifier>',
185
185
  aliases: ['get'],
186
186
  describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
187
187
  handler: wrap(async (argv, app) => {
188
- const id = argv.identifier;
188
+ const id = opts.resolveIdentifier ? await opts.resolveIdentifier(argv, app) : argv.identifier;
189
+ if (id === undefined) return;
189
190
  // Only restrict sysparm_fields if showFields is explicitly set.
190
191
  // Go version fetches all fields for show unless explicitly restricted.
191
192
  const fields = (showFields && showFields.length > 0)
@@ -45,7 +45,7 @@ async function fetchExtensionChain(sdk, record) {
45
45
  return chain;
46
46
  }
47
47
 
48
- export const actionsCmd = (wrap) => buildDevCmd('actions', 'sys_hub_action_type_definition', ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, { singular: 'action', scopeValidation: true });
48
+ export { actionsCmd } from './actions.js';
49
49
 
50
50
  export const includesCmd = (wrap) => buildDevCmd('includes', 'sys_script_include', ['include', 'si'], ['name', 'api_name', 'active', 'sys_scope'], wrap, { singular: 'script include', scopeValidation: true });
51
51
 
@@ -0,0 +1,156 @@
1
+ import { buildDevCmd } from './_generic.js';
2
+ import { formatCommandList, parseDataArg } from '../helpers.js';
3
+ import { resolveRecord, unwrapSysId } from '../resolve-record.js';
4
+ import { declareCapabilities } from '../capabilities.js';
5
+ import { ACTION_TABLE, actionScope } from '../processflow-actions.js';
6
+ import { errUsage } from '../errors.js';
7
+ import { cancelled, pickDesignerRecord, designerScope, guidedActionCreate, guidedActionEdit, guidedActionTestInputs, interactiveDesigner, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
8
+
9
+ const ACTION_COMMANDS = [
10
+ { name: 'list', description: 'List actions' },
11
+ { name: 'show [identifier]', description: 'Show an action by name or sys_id' },
12
+ { name: 'definition [identifier]', description: 'Read the full JSON definition including executable step_instances' },
13
+ { name: 'step-types', description: 'Read step schema templates for a target scope' },
14
+ { name: 'create', description: 'Create an action: guided in a terminal, full JSON definition in scripts' },
15
+ { name: 'update [identifier]', description: 'Save a full definition through Process Flow (alias: edit)' },
16
+ { name: 'test [identifier]', description: 'Execute an action and optionally wait for actual outputs' },
17
+ { name: 'delete [identifier]', description: 'Delete an action parent explicitly' },
18
+ ];
19
+
20
+ const scopeOption = y => y.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id; inferred from existing action when omitted' });
21
+ const dataOptions = y => y
22
+ .option('data-file', { type: 'string', describe: 'Full action definition JSON file, not table fields' })
23
+ .option('data', { type: 'string', describe: 'Full action definition as JSON' });
24
+ async function actionID(argv, app) {
25
+ const identifier = await pickDesignerRecord(argv, app, ACTION_TABLE, 'action');
26
+ if (identifier === undefined) return undefined;
27
+ argv = { ...argv, identifier };
28
+ return unwrapSysId(await resolveRecord(app.sdk, { table: ACTION_TABLE, identifier: argv.identifier, matchField: 'name', resource: 'action' }));
29
+ }
30
+
31
+ export const actionsCmd = wrap => {
32
+ // Reuse the existing list/show picker and output contract only.
33
+ const base = buildDevCmd('actions', ACTION_TABLE, ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, {
34
+ singular: 'action', readOnly: true,
35
+ resolveIdentifier: (argv, app) => cancelled(() => pickDesignerRecord(argv, app, ACTION_TABLE, 'action')),
36
+ showBreadcrumbs: (_record, id) => [
37
+ { action: 'definition', cmd: `jsn actions definition ${id}`, description: 'Read complete executable definition' },
38
+ { action: 'list', cmd: 'jsn actions list', description: 'Back to actions' },
39
+ ],
40
+ });
41
+ declareCapabilities('actions', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'test'] });
42
+ // Retain the common wrap and output controller; domain failures get a
43
+ // structured error envelope and nonzero exit, never a success-shaped dispatch.
44
+ const run = handler => wrap(async (argv, app) => {
45
+ try { await cancelled(() => handler(argv, app)); }
46
+ catch (error) {
47
+ if (!error.code?.startsWith('action_')) throw error;
48
+ if (error.details) error.hint = JSON.stringify(error.details);
49
+ app.err(error);
50
+ process.exitCode = 1;
51
+ }
52
+ });
53
+ return {
54
+ ...base,
55
+ describe: 'Flow Designer actions: create, edit full definitions, and test with outputs',
56
+ builder: y => base.builder(y)
57
+ .command({
58
+ command: 'create', describe: 'Create parent and executable Process Flow action; verify persistence',
59
+ builder: yy => dataOptions(yy).option('scope', { type: 'string', describe: 'Exact target scope sys_id, or global' }),
60
+ handler: run(async (argv, app) => {
61
+ const guided = !hasDefinitionData(argv) ? await guidedActionCreate(argv, app) : undefined;
62
+ const definition = guided?.definition ?? parseDataArg(argv);
63
+ const scope = guided?.scope ?? await designerScope(argv, app);
64
+ const result = await app.sdk.createProcessFlowAction(scope, definition);
65
+ app.ok(result, { summary: `Created and verified action ${result.sys_id}` });
66
+ }),
67
+ })
68
+ .command({
69
+ command: 'definition [identifier]', describe: 'Read full JSON definition including executable step_instances',
70
+ builder: scopeOption,
71
+ handler: run(async (argv, app) => {
72
+ const id = await actionID(argv, app);
73
+ if (id === undefined) return;
74
+ const scope = await actionScope(app.sdk, id, argv.scope);
75
+ app.ok(await app.sdk.getProcessFlowAction(id, scope), { summary: `Read action definition ${id}` });
76
+ }),
77
+ })
78
+ .command({
79
+ command: 'update [identifier]', aliases: ['edit'], describe: 'Save a full definition through Process Flow PUT and verify readback',
80
+ builder: yy => dataOptions(scopeOption(yy)),
81
+ handler: run(async (argv, app) => {
82
+ if (!hasDefinitionData(argv) && !interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the full action definition in noninteractive mode');
83
+ const id = await actionID(argv, app);
84
+ if (id === undefined) return;
85
+ const definition = hasDefinitionData(argv) ? parseDataArg(argv)
86
+ : await guidedActionEdit(argv, app, await app.sdk.getProcessFlowAction(id, await actionScope(app.sdk, id, argv.scope)));
87
+ const result = await app.sdk.updateProcessFlowAction(id, argv.scope, definition);
88
+ app.ok(result, { summary: `Saved and verified action ${id}` });
89
+ }),
90
+ })
91
+ .command({
92
+ command: 'test [identifier]', describe: 'Execute saved or supplied definition; optionally wait for actual outputs',
93
+ builder: yy => dataOptions(scopeOption(yy))
94
+ .option('force', { type: 'boolean', default: false, describe: 'Confirm execution of action scripts, which may have side effects' })
95
+ .option('output-map', { type: 'string', describe: 'JSON action input names to values; sent as outputMap' })
96
+ .option('run-on-thread', { type: 'boolean', default: true, describe: 'Run test on a worker thread' })
97
+ .option('tracing-enabled', { type: 'boolean', default: false, describe: 'Enable Flow Designer tracing' })
98
+ .option('wait', { type: 'boolean', default: false, describe: 'Wait for COMPLETE context and actual runtime outputs' })
99
+ .option('timeout', { type: 'number', default: 60, describe: 'Maximum wait seconds after dispatch, greater than 0, at most 3600' }),
100
+ handler: run(async (argv, app) => {
101
+ let outputMap;
102
+ try { outputMap = JSON.parse(argv['output-map'] ?? '{}'); }
103
+ catch { throw errUsage('--output-map must be valid JSON'); }
104
+ let definition = hasDefinitionData(argv) ? parseDataArg(argv) : undefined;
105
+ const id = await actionID(argv, app);
106
+ if (id === undefined) return;
107
+ if (argv['output-map'] === undefined && interactiveDesigner(argv, app)) {
108
+ definition = await app.sdk.getProcessFlowAction(id, await actionScope(app.sdk, id, argv.scope));
109
+ outputMap = await guidedActionTestInputs(argv, app, definition);
110
+ }
111
+ await confirmDesignerMutation(argv, app, `Execute action '${id}' scripts, which may have side effects`);
112
+ const result = await app.sdk.testProcessFlowAction(id, argv.scope, definition, outputMap, {
113
+ wait: argv.wait, timeout: argv.timeout, runOnThread: argv['run-on-thread'], tracingEnabled: argv['tracing-enabled'],
114
+ });
115
+ app.ok(result, { summary: result.status === 'complete' ? `Test completed ${result.context}` : `Test dispatched ${result.context}; outputs not yet verified` });
116
+ }),
117
+ })
118
+ .command({
119
+ command: 'step-types', describe: 'Read step schema templates for a target scope',
120
+ builder: yy => yy.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id, or global' }),
121
+ handler: run(async (argv, app) => {
122
+ const scope = await designerScope(argv, app);
123
+ app.ok(await app.sdk.getProcessFlowStepTypes(scope), { summary: 'Process Flow step schemas' });
124
+ }),
125
+ })
126
+ .command({
127
+ command: 'delete [identifier]', describe: 'Delete action parent explicitly',
128
+ builder: yy => scopeOption(yy).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
129
+ handler: run(async (argv, app) => {
130
+ const id = await actionID(argv, app);
131
+ if (id === undefined) return;
132
+ await actionScope(app.sdk, id, argv.scope);
133
+ await confirmDesignerMutation(argv, app, `Delete action '${id}'`);
134
+ await app.sdk.delete(ACTION_TABLE, id);
135
+ // Verify the exact target is absent. Do not interpret a 403 as deletion.
136
+ const query = new URLSearchParams({ sysparm_query: `sys_id=${id}`, sysparm_limit: '1', sysparm_fields: 'sys_id', sysparm_display_value: 'false' });
137
+ const response = await app.sdk.request(`${app.sdk.baseURL}/api/now/table/${ACTION_TABLE}?${query}`, { method: 'GET' });
138
+ if (!response || response.error || !Array.isArray(response.result) || response.result.length) {
139
+ throw new Error(`Delete is unverified; action ${id} absence was not explicitly confirmed`);
140
+ }
141
+ app.ok({ name: argv.identifier, sys_id: id, deleted: true }, { summary: `Deleted action ${id}` });
142
+ }),
143
+ }),
144
+ handler: wrap(async (_argv, app) => {
145
+ const summary = 'Manage Flow Designer actions from the sys_hub_action_type_definition table. Create and update guide terminal users; automation passes full JSON definitions, not table fields.';
146
+ app.ok({
147
+ commands: ACTION_COMMANDS.map(command => command.name),
148
+ _formatted: formatCommandList({
149
+ title: summary,
150
+ commands: ACTION_COMMANDS,
151
+ footer: 'Run "jsn actions <command> --help" for details. In a terminal you can omit the target and pick one.',
152
+ }),
153
+ }, { summary });
154
+ }),
155
+ };
156
+ };