@jacebenson/jsn 4.3.2 → 4.3.4-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Works standalone or with AI agents (Claude Code, OpenCode, Hermes, Cursor, Copil
10
10
  npm install -g @jacebenson/jsn
11
11
  ```
12
12
 
13
- Node.js (see `engines` in package.json). macOS, Linux, Windows.
13
+ Node.js 22.5.0 or newer. macOS, Linux, Windows.
14
14
 
15
15
  The install also copies an AI agent skill file to `~/.agents/skills/servicenow/SKILL.md`.
16
16
 
@@ -63,7 +63,7 @@ jsn rules list --query "collection=incident"
63
63
  jsn updatesets set "My Feature"
64
64
 
65
65
  # Generic table access
66
- jsn records list --table incident --limit 50 --json | jq '.[].number'
66
+ jsn records list --table incident --limit 50 --json | jq '.data.records[].number'
67
67
  jsn records create --table incident --data '{"short_description":"test"}'
68
68
  jsn records list --table incident --limit 50 --get "data.records.0.number" # no jq needed
69
69
  jsn records list --table incident --query "active=true" # totals included by default
@@ -145,12 +145,15 @@ jsn auth modify dev12345 # Toggle read-only / skip conf
145
145
 
146
146
  ## Authentication
147
147
 
148
- OAuth 2.0 with PKCE. Credentials in `~/.config/servicenow/credentials/`.
148
+ JSN supports OAuth 2.0 with PKCE, Basic Auth, and browser-session auth. Credentials are stored in the operating system's credential store, with a file fallback under `~/.config/servicenow/credentials/` when the keyring is unavailable.
149
149
 
150
150
  ```bash
151
151
  jsn auth login https://dev12345.service-now.com
152
152
  ```
153
153
 
154
+ For Basic Auth, set `SN_USERNAME` and `SN_PASSWORD`, then run `jsn auth login --basic <instance>`.
155
+ For browser-session auth, run `jsn auth login --gck <instance>` and paste the request headers when prompted.
156
+
154
157
  For CI/CD, set environment variables:
155
158
 
156
159
  ```bash
@@ -164,7 +167,7 @@ jsn incidents list
164
167
  JSN stores durable local data under `~/.jsn/`. Documentation lives in
165
168
  `~/.jsn/docs/`, including `docs.db`, the ServiceNowDocs source checkout, and
166
169
  community markdown. Configuration stays under `~/.config/servicenow/` and
167
- credentials stay under `~/.config/servicenow/credentials/`.
170
+ credentials use the operating system's credential store, with a file fallback under `~/.config/servicenow/credentials/`.
168
171
 
169
172
  Existing installations migrate documentation from
170
173
  `~/.cache/servicenow-cli/docs/` automatically the first time the docs data is
@@ -190,7 +193,7 @@ npm install
190
193
  npm test
191
194
  ```
192
195
 
193
- Releases: `npm run release -- patch` (or `minor`, `major`). Tags, pushes, and publishes to npm via GitHub Actions.
196
+ Releases are manual. After changes land on `main`, run `npm run release -- patch` (or `minor`, `major`) to test, bump the version, and push the commit and tag. When ready, publish to npm with `npm publish --access public`. Git tags mark releases; GitHub Release entries are not required.
194
197
 
195
198
  ## License
196
199
 
package/bin/jsn.js CHANGED
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // A pipeline consumer may exit before JSN finishes writing. Treat that
4
+ // expected closed-pipe condition as a clean CLI exit, not an unhandled error.
5
+ process.stdout.on('error', (err) => {
6
+ if (err.code === 'EPIPE') process.exit(0);
7
+ throw err;
8
+ });
9
+
3
10
  // Suppress the node:sqlite experimental warning on Node 22.x.
4
11
  // SQLite is stable in Node 24+. The patch must be applied before any
5
12
  // module imports node:sqlite, so we use a dynamic import after patching.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.2",
3
+ "version": "4.3.4-dev.4",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "automation"
24
24
  ],
25
25
  "author": "Jace Benson",
26
+ "repository": "https://github.com/jacebenson/jsn",
26
27
  "license": "MIT",
27
28
  "engines": {
28
29
  "node": ">=22.5.0"
@@ -31,14 +32,11 @@
31
32
  "@inquirer/core": "^10.1.0",
32
33
  "@inquirer/prompts": "^7.5.1",
33
34
  "better-sqlite3": "^13.0.3",
34
- "gray-matter": "^4.0.3",
35
- "turndown": "^7.2.4",
36
- "yargs": "^17.7.2"
35
+ "js-yaml": "^4.1.1",
36
+ "turndown": "^7.2.4"
37
37
  },
38
38
  "devDependencies": {
39
- "@11ty/eleventy": "^3.1.6",
40
39
  "@eslint/js": "^10.0.1",
41
- "@inquirer/search": "^4.2.1",
42
40
  "globals": "^17.6.0"
43
41
  },
44
42
  "files": [
@@ -0,0 +1,251 @@
1
+ import { parseArgs } from 'node:util';
2
+ import process from 'node:process';
3
+
4
+ const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5
+ const names = (x) => typeof x === 'string' ? [x] : (x || []);
6
+ const commandName = (command) => command.trim().split(/\s+/)[0];
7
+ const positionSpec = (command) => [...command.matchAll(/([<[])([^>\]]+)[>\]]/g)].map((m) => ({ name: m[2], required: m[1] === '<' }));
8
+
9
+ class CommandNode {
10
+ constructor(definition) {
11
+ this.definition = typeof definition === 'string' ? { command: definition } : definition;
12
+ this.name = commandName(this.definition.command || '');
13
+ this.aliases = names(this.definition.aliases || this.definition.alias);
14
+ this.children = [];
15
+ this.options = new Map();
16
+ this.positionals = new Map();
17
+ this.middlewares = [];
18
+ this.built = false;
19
+ }
20
+
21
+ build() {
22
+ if (this.built) return;
23
+ this.built = true;
24
+ if (typeof this.definition.builder === 'function') this.definition.builder(this.api());
25
+ }
26
+
27
+ api() {
28
+ const node = this;
29
+ return {
30
+ command(definition, builder, handler) {
31
+ node.children.push(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
32
+ return this;
33
+ },
34
+ option(name, config = {}) { node.options.set(name, { ...config, name }); return this; },
35
+ options(config = {}) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; },
36
+ positional(name, config = {}) { node.positionals.set(name, { ...config, name }); return this; },
37
+ demandCommand(count, message) { node.demand = { count, message }; return this; },
38
+ middleware(fn) { node.middlewares.push(...(Array.isArray(fn) ? fn : [fn])); return this; },
39
+ strict() { return this; },
40
+ check() { return this; },
41
+ usage(value) { node.usageText = value; return this; },
42
+ epilogue(value) { node.epilogueText = value; return this; },
43
+ help() { return this; },
44
+ };
45
+ }
46
+ }
47
+
48
+ export class LocalCLI {
49
+ constructor(args = []) {
50
+ this.args = [...args];
51
+ this.root = new CommandNode({ command: '' });
52
+ this.root.build = () => {};
53
+ this.root.api = this.root.api.bind(this.root);
54
+ this.middlewares = [];
55
+ this.failHandler = null;
56
+ this.script = 'jsn';
57
+ this.completionCommand = '__completion';
58
+ }
59
+
60
+ scriptName(value) { this.script = value; return this; }
61
+ usage(value) { this.root.usageText = value; return this; }
62
+ middleware(fn) { this.middlewares.push(...(Array.isArray(fn) ? fn : [fn])); return this; }
63
+ option(name, config) { this.root.options.set(name, { ...config, name }); return this; }
64
+ options(config) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; }
65
+ command(definition, builder, handler) {
66
+ const node = new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition);
67
+ this.root.children.push(node);
68
+ return this;
69
+ }
70
+ demandCommand(count, message) { this.root.demand = { count, message }; return this; }
71
+ completion(name, _description, handler) { this.completionCommand = name; this.completionHandler = handler; return this; }
72
+ help() { this.helpEnabled = true; return this; }
73
+ version() { return this; }
74
+ strictCommands() { this.strictCmd = true; return this; }
75
+ strictOptions() { return this; }
76
+ fail(fn) { this.failHandler = fn; return this; }
77
+ getInternalMethods() {
78
+ const all = this.root.children;
79
+ return { getCommandInstance: () => ({
80
+ getCommands: () => all.flatMap((n) => [n.name, ...n.aliases]),
81
+ getCommandHandlers: () => Object.fromEntries(all.map((n) => [n.name, n.definition.handler])),
82
+ }) };
83
+ }
84
+ showCompletionScript() {
85
+ process.stdout.write(`###-begin-jsn-completions-###\n_jsn_yargs_completions() {\n local completions=$(jsn --get-yargs-completions "$@")\n COMPREPLY=( $(compgen -W "$completions" -- "\${COMP_WORDS[COMP_CWORD]}") )\n}\ncomplete -F _jsn_yargs_completions jsn\n###-end-jsn-completions-###\n`);
86
+ }
87
+
88
+ _lookup(list, value) { return list.find((n) => n.name === value || n.aliases.includes(value)); }
89
+ _knownOptions(nodes) {
90
+ const map = new Map();
91
+ for (const node of [this.root, ...nodes]) for (const [name, spec] of node.options) {
92
+ map.set(name, spec); for (const alias of names(spec.alias)) map.set(alias, spec);
93
+ }
94
+ return map;
95
+ }
96
+ _findPath(args) {
97
+ const path = [], nodes = [this.root], consumed = new Set(), discoveryConsumed = new Set(); let i = 0;
98
+ while (i < args.length) {
99
+ const token = args[i];
100
+ if (token === '--') break;
101
+ if (token.startsWith('-')) {
102
+ const key = token.replace(/^-+/, '').split('=')[0];
103
+ const spec = this._knownOptions(path).get(key);
104
+ if (!token.includes('=') && (!spec || spec.type !== 'boolean') && args[i + 1] && !args[i + 1].startsWith('-')) {
105
+ discoveryConsumed.add(i + 1);
106
+ i++;
107
+ }
108
+ discoveryConsumed.add(i);
109
+ i++; continue;
110
+ }
111
+ const node = this._lookup(nodes[nodes.length - 1].children, token);
112
+ if (!node) break;
113
+ consumed.add(i);
114
+ path.push(node); nodes.push(node); node.build(); i++;
115
+ }
116
+ return {
117
+ path,
118
+ commandArgs: args.filter((_, index) => !consumed.has(index)),
119
+ discoveryConsumed: new Set([...consumed, ...discoveryConsumed]),
120
+ };
121
+ }
122
+ _optionConfig(path) { return this._knownOptions(path); }
123
+ _specFor(options, key, value) {
124
+ return options.get(key) || [...options.values()].find((spec) => spec.name === key || names(spec.alias).includes(key))
125
+ || { name: key, type: typeof value === 'boolean' ? 'boolean' : 'string' };
126
+ }
127
+ _assign(argv, key, value, spec) {
128
+ const actual = spec?.name || key;
129
+ const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
130
+ if (spec?.choices && values.some((item) => !spec.choices.includes(item))) {
131
+ this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
132
+ return false;
133
+ }
134
+ if (spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
135
+ this._fail(`Invalid number for --${actual}: ${value}`);
136
+ return false;
137
+ }
138
+ const converted = spec?.type === 'boolean'
139
+ ? (value === 'true' ? true : value === 'false' ? false : value)
140
+ : spec?.type === 'number' ? Number(value) : value;
141
+ if (spec?.type === 'array') argv[actual] = [...(argv[actual] || []), ...values];
142
+ else argv[actual] = converted;
143
+ argv[camel(actual)] = argv[actual];
144
+ for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
145
+ }
146
+ _parseOptions(args, path) {
147
+ const options = this._optionConfig(path);
148
+ const defs = {};
149
+ for (const [key, spec] of options) defs[key] = {
150
+ type: spec.type === 'number' || spec.type === 'array' ? 'string' : (spec.type || 'boolean'),
151
+ multiple: spec.type === 'array',
152
+ };
153
+ args = args.map((arg) => {
154
+ if (/^-[^-]=/.test(arg)) return `--${arg.slice(1)}`;
155
+ if (!arg.startsWith('--no-')) return arg;
156
+ const [key, rawValue] = arg.slice(5).split('=', 2);
157
+ const spec = this._specFor(options, key);
158
+ if (spec.type !== 'boolean') return arg;
159
+ const value = rawValue === undefined ? 'false' : rawValue === 'false' ? 'true' : 'false';
160
+ return `--${key}=${value}`;
161
+ });
162
+ for (const [key, spec] of options) for (const alias of names(spec.alias)) if (!defs[alias]) defs[alias] = defs[key];
163
+ let parsed;
164
+ try { parsed = parseArgs({ args, options: defs, allowPositionals: true, strict: false, tokens: true }); }
165
+ catch (err) { this._fail(err.message, err); return { values: {}, positionals: [] }; }
166
+ const argv = { _: path.map((n) => n.name) };
167
+ for (const [key, value] of Object.entries(parsed.values)) {
168
+ const spec = this._specFor(options, key, value);
169
+ if (this._assign(argv, key, value, spec) === false) return null;
170
+ }
171
+ for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec);
172
+ return { values: argv, positionals: parsed.positionals };
173
+ }
174
+ _fail(message, error) {
175
+ if (this.failHandler) return this.failHandler(message, error);
176
+ throw error || new Error(message);
177
+ }
178
+ _help(node, path = []) {
179
+ const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
180
+ if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
181
+ if (node.children.length) lines.push('\nCommands:\n' + node.children.filter((n) => n.name !== this.completionCommand).map((n) => ` ${n.definition.command} ${n.definition.describe || ''}`).join('\n'));
182
+ if (node.options.size) lines.push('\nOptions:\n' + [...node.options].map(([name, s]) => ` --${name}${s.alias ? `, -${names(s.alias).join(', -')}` : ''} ${s.describe || s.description || ''}`).join('\n'));
183
+ if (node.epilogueText) lines.push(`\n${node.epilogueText}`);
184
+ process.stdout.write(lines.join('\n') + '\n');
185
+ }
186
+ async parse() {
187
+ if (this.args.includes('--get-yargs-completions')) {
188
+ const prefix = this.args.at(-1) || '';
189
+ let completionArgs = this.args.slice(this.args.indexOf('--get-yargs-completions') + 1, -1);
190
+ if (completionArgs[0] === this.script) completionArgs = completionArgs.slice(1);
191
+ const { path } = this._findPath(completionArgs);
192
+ const node = path.at(-1) || this.root;
193
+ node.build();
194
+ const argv = { _: path.map((n) => n.name) };
195
+ const completionFilter = (callback) => {
196
+ const values = [...node.children].filter((n) => n.name !== this.completionCommand)
197
+ .flatMap((n) => path.length ? [n.name] : [n.name, ...n.aliases]);
198
+ const opts = [...this._optionConfig(path)].map(([name]) => `--${name}`);
199
+ callback(null, [...new Set([...values, ...opts])]);
200
+ };
201
+ const finish = (values) => process.stdout.write([...new Set(values || [])]
202
+ .filter((x) => x.startsWith(prefix) || prefix.startsWith('-') && x.startsWith(prefix)).join('\n') + '\n');
203
+ if (this.completionHandler) {
204
+ this.completionHandler(prefix, argv, completionFilter, finish);
205
+ } else {
206
+ completionFilter((err, values) => finish(values));
207
+ }
208
+ return;
209
+ }
210
+ if (this.args[0] === 'help') {
211
+ const target = this._findPath(this.args.slice(1));
212
+ if (this.args.length === 1 || target.path.length) {
213
+ const node = target.path.at(-1) || this.root;
214
+ node.build();
215
+ this._help(node, target.path.map((n) => n.name));
216
+ return;
217
+ }
218
+ }
219
+ const { path, commandArgs, discoveryConsumed } = this._findPath(this.args);
220
+ const node = path.at(-1) || this.root;
221
+ node.build();
222
+ if (commandArgs.includes('--help') || commandArgs.includes('-h')) { this._help(node, path.map((n) => n.name)); return; }
223
+ const firstWord = this.args.find((arg, index) => !discoveryConsumed.has(index) && !arg.startsWith('-'));
224
+ if (this.strictCmd && !path.length && firstWord && !this._lookup(this.root.children, firstWord)) {
225
+ return this._fail(`Unknown command: ${firstWord}`);
226
+ }
227
+ if (this.strictCmd && path.length && node.children.length && firstWord
228
+ && !this._lookup(node.children, firstWord)) {
229
+ return this._fail(`Unknown command: ${firstWord}`);
230
+ }
231
+ if (!path.length && this.root.demand) return this._fail(this.root.demand.message);
232
+ const parsed = this._parseOptions(commandArgs, path);
233
+ if (!parsed) return;
234
+ const argv = parsed.values;
235
+ const specs = [...this._optionConfig(path).values()];
236
+ const positions = positionSpec(node.definition.command || '').map((position) => ({
237
+ ...position, ...node.positionals.get(position.name), name: position.name,
238
+ }));
239
+ parsed.positionals.forEach((value, i) => { if (positions[i]) argv[positions[i].name] = value; });
240
+ for (const spec of specs) if (spec.demandOption && argv[spec.name] === undefined) return this._fail(`Missing required argument: --${spec.name}`);
241
+ for (const pos of positions) if (pos.required && argv[pos.name] === undefined) return this._fail(`Missing required argument: ${pos.name}`);
242
+ for (const middleware of this.middlewares) await middleware(argv);
243
+ for (const commandNode of path) for (const middleware of commandNode.middlewares) await middleware(argv);
244
+ if (node.definition.handler) await node.definition.handler(argv);
245
+ else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
246
+ }
247
+ }
248
+
249
+ export function yargs(args = []) { return new LocalCLI(args); }
250
+ export function hideBin(argv) { return argv.slice(2); }
251
+ export default yargs;
package/src/cli.js CHANGED
@@ -1,7 +1,6 @@
1
- // Root CLI using yargs
1
+ // Root CLI using the local yargs-compatible adapter
2
2
 
3
- import yargs from 'yargs';
4
- import { hideBin } from 'yargs/helpers';
3
+ import yargs, { hideBin } from './cli-adapter.js';
5
4
  import process from 'node:process';
6
5
  import path from 'node:path';
7
6
  import fs from 'node:fs';
@@ -387,14 +386,13 @@ export function buildCLI() {
387
386
  // 2. When the current word is an exact ALIAS (e.g. "inc" for incidents),
388
387
  // yargs descends into that command's subcommands, so a still-being-
389
388
  // typed "jsn inc<TAB>" offers subcommands that can't match the prefix.
390
- // When completing the FIRST word (depth 1, the command slot), merge
391
- // in the root command list — the shell's compgen prefix-filters the
392
- // final list anyway.
389
+ // When completing the FIRST word, merge in the root command list — the
390
+ // shell's compgen prefix-filters the final list anyway.
393
391
  .completion('__completion', false, (current, argv, completionFilter, done) => {
394
392
  completionFilter((err, completions) => {
395
393
  let out = completions || [];
396
394
  const depth = (argv._ || []).length;
397
- if (current && !current.startsWith('-') && depth <= 2) {
395
+ if (current && !current.startsWith('-') && depth === 0) {
398
396
  out = [...new Set([...out, ...rootCommands])];
399
397
  }
400
398
  done([...new Set(out)]);
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import process from 'node:process';
10
10
  import TurndownService from 'turndown';
11
- import matter from 'gray-matter';
11
+ import { parseFrontmatter, stringifyFrontmatter } from '../../frontmatter.js';
12
12
  import Database from 'better-sqlite3';
13
13
  import { getDocsCommunityDir, getDocsDbPath, docsDbExists } from './db.js';
14
14
  import { refreshDocs } from './refresh.js';
@@ -166,7 +166,7 @@ export function slugify(title, fallback = 'untitled') {
166
166
  }
167
167
 
168
168
  function frontmatter(body, data) {
169
- return matter.stringify(body, data);
169
+ return stringifyFrontmatter(body, data);
170
170
  }
171
171
 
172
172
  // --- Write a community doc to disk ---
@@ -311,7 +311,7 @@ function buildUrlIndex() {
311
311
  if (entry.isDirectory()) walk(full);
312
312
  else if (entry.name.toLowerCase().endsWith('.md')) {
313
313
  try {
314
- const parsed = matter(fs.readFileSync(full, 'utf8'));
314
+ const parsed = parseFrontmatter(fs.readFileSync(full, 'utf8'));
315
315
  if (parsed.data.canonical_url) index.set(parsed.data.canonical_url, full);
316
316
  } catch { /* ignore */ }
317
317
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import { createHash } from 'node:crypto';
5
- import matter from 'gray-matter';
5
+ import { parseFrontmatter } from '../../frontmatter.js';
6
6
  import { openDocsDb, closeDocsDb, initDocsSchema, getDocsDbPath, walkDocs, defaultDocsRoots } from './db.js';
7
7
  import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
8
8
 
@@ -35,7 +35,7 @@ export function ingestDocs(opts = {}) {
35
35
  let data = {};
36
36
  let body = raw;
37
37
  try {
38
- const parsed = matter(raw);
38
+ const parsed = parseFrontmatter(raw);
39
39
  data = parsed.data || {};
40
40
  body = parsed.content || '';
41
41
  } catch {
@@ -3,7 +3,7 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import { createHash } from 'node:crypto';
6
- import matter from 'gray-matter';
6
+ import { parseFrontmatter } from '../../frontmatter.js';
7
7
  import { openDocsDb, closeDocsDb, getDocsDbPath, walkDocs, defaultDocsRoots } from './db.js';
8
8
  import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
9
9
 
@@ -72,7 +72,7 @@ export function refreshDocs(opts = {}) {
72
72
  const stat = fs.statSync(file);
73
73
  let data = {}, body = raw;
74
74
  try {
75
- const parsed = matter(raw);
75
+ const parsed = parseFrontmatter(raw);
76
76
  data = parsed.data || {};
77
77
  body = parsed.content || '';
78
78
  } catch {
@@ -1,3 +1,4 @@
1
+ import { createPrompt, useKeypress, usePagination, useState, isDownKey, isEnterKey, isUpKey } from '@inquirer/core';
1
2
  import { loginWizard, modifyProfile, removeProfile, pickProfile } from './auth.js';
2
3
  import { setActiveProfile } from '../config.js';
3
4
  import { errUsage } from '../errors.js';
@@ -7,18 +8,44 @@ import { declareCapabilities } from '../capabilities.js';
7
8
  // (Not in the daily-check skip-list: legacy behavior checks for updates here.)
8
9
  declareCapabilities('setup', { noInstance: true });
9
10
 
10
- /** Interactive hub: what do you want to do? Returns 'add' | 'switch' | 'remove' | 'modify'. */
11
- export async function authHubMenu() {
12
- const { select } = await import('@inquirer/prompts');
13
- return select({
14
- message: 'What would you like to do?',
15
- choices: [
16
- { name: 'Add a new instance', value: 'add' },
17
- { name: 'Switch to a different instance', value: 'switch' },
18
- { name: 'Remove an instance', value: 'remove' },
19
- { name: 'Modify an instance', value: 'modify' },
20
- ],
11
+ const HUB_CHOICES = [
12
+ { name: 'Add a new instance', value: 'add' },
13
+ { name: 'Switch to a different instance', value: 'switch' },
14
+ { name: 'Remove an instance', value: 'remove' },
15
+ { name: 'Modify an instance', value: 'modify' },
16
+ ];
17
+
18
+ /** The setup hub prompt; q and Escape resolve with no selected action. */
19
+ export const authHubPrompt = createPrompt((config, done) => {
20
+ const [active, setActive] = useState(0);
21
+ const choices = config.choices;
22
+
23
+ useKeypress((key) => {
24
+ if (key.name === 'q' || key.name === 'escape') {
25
+ done(undefined);
26
+ return;
27
+ }
28
+ if (isEnterKey(key)) {
29
+ done(choices[active].value);
30
+ return;
31
+ }
32
+ if (isUpKey(key) && active > 0) setActive(active - 1);
33
+ if (isDownKey(key) && active < choices.length - 1) setActive(active + 1);
21
34
  });
35
+
36
+ const page = usePagination({
37
+ items: choices,
38
+ active,
39
+ pageSize: choices.length,
40
+ loop: false,
41
+ renderItem: ({ item, isActive }) => `${isActive ? '❯' : ' '} ${item.name}`,
42
+ });
43
+ return `${config.message}\n${page}\n\n↑↓ navigate • ⏎ select • q/Esc exit`;
44
+ });
45
+
46
+ /** Interactive hub: what do you want to do? Returns an action or undefined on exit. */
47
+ export async function authHubMenu({ promptFn = authHubPrompt, input, output } = {}) {
48
+ return promptFn({ message: 'What would you like to do?', choices: HUB_CHOICES }, { input, output });
22
49
  }
23
50
 
24
51
  /**
@@ -36,7 +63,7 @@ export async function dispatchSetupAction(app, argv, action) {
36
63
  } else if (action === 'remove') {
37
64
  const name = await pickProfile(app, 'Remove which instance?');
38
65
  await removeProfile(app, name);
39
- } else { // modify
66
+ } else if (action === 'modify') {
40
67
  await modifyProfile(app, argv);
41
68
  }
42
69
  }
@@ -60,6 +87,7 @@ export function setupCmd(wrap) {
60
87
  return;
61
88
  }
62
89
  const action = await authHubMenu();
90
+ if (action === undefined) return;
63
91
  await dispatchSetupAction(app, argv, action);
64
92
  }),
65
93
  };
@@ -330,7 +330,7 @@ export function skillCmd(wrap) {
330
330
  ? `Where should the jsn skill be installed? (${outdated.length} outdated)`
331
331
  : 'Where should the jsn skill be installed?';
332
332
 
333
- const { default: checkbox } = await import('@inquirer/checkbox');
333
+ const { checkbox } = await import('@inquirer/prompts');
334
334
  const choices = Object.entries(TARGET_NAMES).map(([key, label]) => {
335
335
  const dir = AGENT_SKILL_DIRS[key];
336
336
  const targetPath = key === 'cursor'
@@ -0,0 +1,48 @@
1
+ import yaml from 'js-yaml';
2
+
3
+ const DEFAULT_DELIMITERS = ['---', '---'];
4
+
5
+ function delimiters(options = {}) {
6
+ const value = options.delimiters || DEFAULT_DELIMITERS;
7
+ if (!Array.isArray(value) || value.length !== 2 || value.some((item) => typeof item !== 'string')) {
8
+ throw new TypeError('delimiters must be a two-item string array');
9
+ }
10
+ return value;
11
+ }
12
+
13
+ /** Parse the YAML frontmatter format used by the docs commands. */
14
+ export function parseFrontmatter(input, options = {}) {
15
+ const source = String(input).replace(/^\uFEFF/, '');
16
+ const [open, close] = delimiters(options);
17
+ const empty = { data: {}, content: source };
18
+
19
+ if (!source.startsWith(open) || source.charAt(open.length) === open.slice(-1)) return empty;
20
+
21
+ let frontmatter = source.slice(open.length);
22
+ // gray-matter accepts an optional language suffix, which is only relevant
23
+ // here for the YAML spelling (`---yaml`).
24
+ if (frontmatter.startsWith('yaml\r\n') || frontmatter.startsWith('yaml\n')) {
25
+ frontmatter = frontmatter.slice(4);
26
+ }
27
+
28
+ const closeIndex = frontmatter.indexOf(`\n${close}`);
29
+ const raw = closeIndex === -1 ? frontmatter : frontmatter.slice(0, closeIndex);
30
+ const parsed = yaml.load(raw);
31
+ const data = parsed === undefined ? {} : parsed;
32
+
33
+ if (closeIndex === -1) return { data, content: '' };
34
+
35
+ let content = frontmatter.slice(closeIndex + close.length + 1);
36
+ if (content.startsWith('\r')) content = content.slice(1);
37
+ if (content.startsWith('\n')) content = content.slice(1);
38
+ return { data, content };
39
+ }
40
+
41
+ /** Serialize docs metadata and body using gray-matter-compatible YAML framing. */
42
+ export function stringifyFrontmatter(body, data, options = {}) {
43
+ const [open, close] = delimiters(options);
44
+ const yamlText = yaml.dump(data, options).trim();
45
+ const prefix = yamlText === '{}' ? '' : `${open}\n${yamlText}\n${close}\n`;
46
+ const content = String(body);
47
+ return prefix + (content.endsWith('\n') ? content : `${content}\n`);
48
+ }
package/src/sdk.js CHANGED
@@ -6,6 +6,24 @@ import { getStringField } from './helpers.js';
6
6
 
7
7
  const DEFAULT_TIMEOUT = 30000;
8
8
 
9
+ function collectSetCookies(cookieJar, response) {
10
+ const values = response.headers.getSetCookie?.() || [];
11
+ const raw = Array.isArray(values) && values.length
12
+ ? values
13
+ : [response.headers.get('set-cookie')].filter(Boolean);
14
+ for (const value of raw) {
15
+ const pair = value.split(';', 1)[0].trim();
16
+ const separator = pair.indexOf('=');
17
+ if (separator > 0) {
18
+ cookieJar.set(pair.slice(0, separator), pair.slice(separator + 1));
19
+ }
20
+ }
21
+ }
22
+
23
+ function serializeCookies(cookieJar) {
24
+ return [...cookieJar].map(([name, value]) => `${name}=${value}`).join('; ');
25
+ }
26
+
9
27
  // Build the sys.scripts.do POST body for background script execution.
10
28
  // Extracted for testability (issue #177): maps script-mode options to the
11
29
  // form fields on ServiceNow's Script Background form. Defaults preserve the
@@ -57,7 +75,11 @@ export class SDKClient {
57
75
  case 'oauth':
58
76
  if (!skipOAuthSession) {
59
77
  const cookies = await this._warmSession();
60
- if (cookies) req.headers.set('Cookie', cookies);
78
+ if (cookies && this.sessionUserToken) {
79
+ req.headers.set('Cookie', cookies);
80
+ req.headers.set('X-UserToken', this.sessionUserToken);
81
+ break;
82
+ }
61
83
  }
62
84
  req.headers.set('Authorization', `Bearer ${creds.access_token}`);
63
85
  break;
@@ -798,28 +820,26 @@ export class SDKClient {
798
820
  try {
799
821
  const creds = await this.authProvider.getCredentials();
800
822
  if (creds.auth_method === 'oauth') {
801
- let cookies = '';
802
- const collectCookies = (resp) => {
803
- const values = resp.headers.getSetCookie?.() || [];
804
- const raw = Array.isArray(values) ? values : [resp.headers.get('set-cookie')].filter(Boolean);
805
- const next = raw.map(value => value.split(';', 1)[0]).filter(Boolean);
806
- if (next.length) cookies = [...new Set(`${cookies}; ${next.join('; ')}`.split(';').map(v => v.trim()).filter(Boolean))].join('; ');
807
- };
823
+ const cookieJar = new Map();
808
824
  const login = `${this.baseURL}/angular.do?sysparm_type=get_user`;
809
- collectCookies(await fetch(new Request(login, { method: 'POST' })));
825
+ collectSetCookies(cookieJar, await fetch(new Request(login, { method: 'POST' })));
810
826
  const scopeEndpoint = `${this.baseURL}/api/now/table/sys_scope?sysparm_fields=sys_id%2Csys_class_name&sysparm_limit=1`;
811
- collectCookies(await this._fetchWithAuth(scopeEndpoint, {
827
+ collectSetCookies(cookieJar, await this._fetchWithAuth(scopeEndpoint, {
812
828
  method: 'GET',
813
- headers: cookies ? { Cookie: cookies } : {},
829
+ headers: serializeCookies(cookieJar) ? { Cookie: serializeCookies(cookieJar) } : {},
814
830
  }));
815
- collectCookies(await this._fetchWithAuth(login, {
831
+ const finalLogin = await fetch(new Request(login, {
816
832
  method: 'POST',
817
- headers: cookies ? { Cookie: cookies } : {},
833
+ headers: serializeCookies(cookieJar) ? { Cookie: serializeCookies(cookieJar) } : {},
818
834
  }));
835
+ collectSetCookies(cookieJar, finalLogin);
836
+ this.sessionUserToken = finalLogin.headers.get('x-usertoken-response') || '';
837
+ const cookies = serializeCookies(cookieJar);
819
838
  this.oauthCookies = cookies;
820
839
  return cookies;
821
840
  }
822
841
  const endpoint = `${this.baseURL}/angular.do`;
842
+ const cookieJar = new Map();
823
843
  const loginResp = await this._fetchWithAuth(`${endpoint}?sysparm_type=view_form.login`, {
824
844
  method: 'POST',
825
845
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -830,14 +850,14 @@ export class SDKClient {
830
850
  user_password: creds.password,
831
851
  }).toString(),
832
852
  });
833
- const values = loginResp.headers.getSetCookie?.() || [];
834
- const raw = Array.isArray(values) ? values : [loginResp.headers.get('set-cookie')].filter(Boolean);
835
- const cookies = raw.map(value => value.split(';', 1)[0]).filter(Boolean).join('; ');
853
+ collectSetCookies(cookieJar, loginResp);
836
854
  const refreshResp = await this._fetchWithAuth(`${endpoint}?sysparm_type=get_user`, {
837
855
  method: 'POST',
838
- headers: cookies ? { Cookie: cookies } : {},
856
+ headers: serializeCookies(cookieJar) ? { Cookie: serializeCookies(cookieJar) } : {},
839
857
  });
858
+ collectSetCookies(cookieJar, refreshResp);
840
859
  this.sessionUserToken = refreshResp.headers.get('x-usertoken-response') || '';
860
+ const cookies = serializeCookies(cookieJar);
841
861
  this.oauthCookies = cookies;
842
862
  return cookies;
843
863
  } catch {