@jacebenson/jsn 4.3.12 → 4.3.13-dev.72.1

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
@@ -13,6 +13,47 @@ jsn setup
13
13
 
14
14
  See the [setup guide](docs/docs-setup.md) to connect an instance safely.
15
15
 
16
+ ### Dev channel
17
+
18
+ For the newest changes from `main`, including changes that may be unstable:
19
+
20
+ ```bash
21
+ npm install -g @jacebenson/jsn@dev
22
+ ```
23
+
24
+ Dev builds check the dev channel for updates during normal use, at most once
25
+ per day. Run `jsn version --check` to check immediately. To install the newest
26
+ dev build, run `npm install -g @jacebenson/jsn@dev` again. Updates are manual.
27
+ Return to stable with `npm install -g @jacebenson/jsn@latest`.
28
+
29
+ Pushes to `main` promote the commit to `dev` and publish a tested prerelease
30
+ under npm's `dev` tag. Versions include the workflow run number and attempt
31
+ (for example, `4.3.9-dev.123.1`); they do not move npm's stable `latest` tag.
32
+ The npm trusted publisher for `publish-dev.yml` must authorize the repository's
33
+ `dev` GitHub environment, alongside the production publishing configuration.
34
+
35
+ ### Supported versions
36
+
37
+ Only the current release and the two preceding releases in each channel are
38
+ supported (three stable releases and three dev builds). For example, with
39
+ stable `4.3.11`, versions `4.3.11`, `4.3.10`, and `4.3.9` are supported.
40
+ Update to the newest version in your channel before reporting an issue.
41
+
42
+ After each successful npm publish, deployment deprecates older versions in that
43
+ channel. Deprecated versions remain installable and appear in history, but npm
44
+ prints an update warning on installation. Existing deprecation messages are
45
+ preserved. No versions are unpublished.
46
+
47
+ The deprecation step uses the repository's `NPM_TOKEN` secret with permission to
48
+ modify `@jacebenson/jsn` and noninteractive authentication (including bypass 2FA
49
+ where required). Publishing continues to use OIDC. A deprecation failure fails
50
+ the workflow after publishing; it does not roll back the published package.
51
+ To retry only deprecation after fixing authentication, use the matching release
52
+ checkout and run `node scripts/deprecate-old-versions.mjs latest --apply` (or
53
+ `dev --apply`) with npm authentication configured and `NODE_AUTH_TOKEN` set.
54
+ Omit `--apply` to preview the affected versions without changing npm. The script
55
+ requires the checkout's package version to match the channel's current npm tag.
56
+
16
57
  ## Try it
17
58
 
18
59
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.12",
3
+ "version": "4.3.13-dev.72.1",
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": {
package/src/app.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // command handlers that already call them.
9
9
 
10
10
  import { AuthManager } from './auth.js';
11
+ import { createAuthRecovery } from './auth-recovery.js';
11
12
  import { SDKClient } from './sdk.js';
12
13
  import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled, hyperlink } from './output.js';
13
14
  import { saveConfig } from './config.js';
@@ -53,8 +54,11 @@ export class App {
53
54
 
54
55
  /** Build (or rebuild) the SDK client for an instance + session. */
55
56
  _buildSdk(instance, session = this.session) {
56
- this.sdk = new SDKClient(instance, this.auth, {
57
+ this.sdk = new SDKClient(instance, this.auth.createProfileProvider(instance, {
58
+ username: session?.username, authMethod: session?.profile?.auth_method,
59
+ }), {
57
60
  domain: session?.domain || '',
61
+ recovery: createAuthRecovery({ app: this, session }),
58
62
  });
59
63
  }
60
64
 
@@ -0,0 +1,74 @@
1
+ import { errAPI } from './errors.js';
2
+ import { canUsePicker, quoteShellArg } from './helpers.js';
3
+ import { confirm } from './prompts.js';
4
+
5
+ // Recovery is scoped to the SDK's resolved identity, never the mutable default.
6
+ // error supplies the same safe guidance if the single retry is also rejected.
7
+ export function createAuthRecovery({ app, session, argv = () => app._authArgv || {},
8
+ canInteract = canUsePicker, prompt = confirm,
9
+ renewBrowser = async (request, targetApp) => {
10
+ const { refreshProfile } = await import('./commands/auth.js');
11
+ return refreshProfile(request, targetApp);
12
+ },
13
+ }) {
14
+ const { instance, profileName, username, profile } = session;
15
+ const target = profileName ? `--profile ${quoteShellArg(profileName)}` : quoteShellArg(instance);
16
+ const label = profileName ? `profile ${quoteShellArg(profileName)}` : 'the active profile';
17
+ function failure(method = profile?.auth_method) {
18
+ const error = errAPI(401, '');
19
+ const login = `jsn auth login ${target}`;
20
+ error.message = `Authentication rejected for ${label} (HTTP 401).`;
21
+ error.profile = profileName || null;
22
+ error.recovery = method === 'basic'
23
+ ? `jsn auth login --basic ${quoteShellArg(instance)}${profileName ? ` --profile ${quoteShellArg(profileName)}` : ''}`
24
+ : method === 'gck' ? `jsn auth refresh ${target} --browser`
25
+ : method === 'oauth' ? `jsn auth refresh ${target}` : login;
26
+ error.hint = method === 'basic'
27
+ ? `Set SN_USERNAME/SN_PASSWORD to the correct credentials, then run: ${error.recovery}`
28
+ : method === 'oauth'
29
+ ? `Run interactively: ${error.recovery}. If refresh fails, run: ${login}`
30
+ : `Run: ${error.recovery}`;
31
+ return error;
32
+ }
33
+ // Keep the settled promise too: this provider gets only one recovery attempt.
34
+ let recoveryAttempt;
35
+ const provider = {
36
+ error: failure(),
37
+ async recover({ status }) {
38
+ if (status !== 401) return false;
39
+ return recoveryAttempt ??= recoverOnce();
40
+ },
41
+ };
42
+ async function recoverOnce() {
43
+ let creds;
44
+ try {
45
+ creds = await app.auth.getCredentialsFor(instance, username, {
46
+ username, authMethod: profile?.auth_method, refresh: false,
47
+ });
48
+ } catch { throw provider.error; }
49
+ const method = profile?.auth_method || creds.auth_method;
50
+ provider.error = failure(method);
51
+ if (method === 'oauth' && creds.refresh_token) {
52
+ try {
53
+ await app.auth.refreshToken(instance, creds, username ?? null);
54
+ return true;
55
+ } catch { throw provider.error; }
56
+ }
57
+ if (method === 'gck' && profileName && ['auto', 'confirm'].includes(profile?.reauth ?? 'confirm')
58
+ && canInteract(app, argv())) {
59
+ try {
60
+ if ((profile?.reauth ?? 'confirm') === 'confirm'
61
+ && !await prompt({ message: `Sign in again in a browser for ${label}?`, default: false })) throw provider.error;
62
+ // Reuse the canonical GCK refresh/login operation without emitting a
63
+ // second success envelope or replacing the in-flight command's SDK.
64
+ const targetApp = Object.create(app);
65
+ targetApp.ok = () => {};
66
+ targetApp.sdk = app.getSDKForProfile(instance, { profile, authMethod: 'gck', username });
67
+ await renewBrowser({ profile: profileName, browser: true }, targetApp);
68
+ return true;
69
+ } catch { throw provider.error; }
70
+ }
71
+ throw provider.error;
72
+ }
73
+ return provider;
74
+ }
@@ -3,6 +3,14 @@ import process from 'node:process';
3
3
 
4
4
  const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5
5
  const names = (x) => typeof x === 'string' ? [x] : (x || []);
6
+
7
+ /**
8
+ * A repeatable option may be declared either way: `type: 'array'` (this
9
+ * adapter's historic spelling) or `array: true` (the yargs spelling most
10
+ * contributors reach for). Both must accumulate; honoring only the first
11
+ * silently kept the LAST value of `-f a=1 -f b=2` and dropped the rest.
12
+ */
13
+ const isArrayOption = (spec) => spec?.type === 'array' || spec?.array === true;
6
14
  const commandName = (command) => command.trim().split(/\s+/)[0];
7
15
  const positionSpec = (command) => [...command.matchAll(/([<[])([^>\]]+)[>\]]/g)].map((m) => ({ name: m[2], required: m[1] === '<' }));
8
16
 
@@ -189,7 +197,7 @@ export class LocalCLI {
189
197
  _assign(argv, key, value, spec, opts = {}) {
190
198
  const validate = opts.validate !== false;
191
199
  const actual = spec?.name || key;
192
- const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
200
+ const values = isArrayOption(spec) ? (Array.isArray(value) ? value : [value]) : [value];
193
201
  if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
194
202
  this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
195
203
  return false;
@@ -201,7 +209,7 @@ export class LocalCLI {
201
209
  const converted = spec?.type === 'boolean'
202
210
  ? (value === 'true' ? true : value === 'false' ? false : value)
203
211
  : spec?.type === 'number' ? Number(value) : value;
204
- if (spec?.type === 'array') argv[actual] = [...(argv[actual] || []), ...values];
212
+ if (isArrayOption(spec)) argv[actual] = [...(argv[actual] || []), ...values];
205
213
  else argv[actual] = converted;
206
214
  argv[camel(actual)] = argv[actual];
207
215
  for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
@@ -210,8 +218,8 @@ export class LocalCLI {
210
218
  const options = this._optionConfig(path);
211
219
  const defs = {};
212
220
  for (const [key, spec] of options) defs[key] = {
213
- type: spec.type === 'number' || spec.type === 'array' ? 'string' : (spec.type || 'boolean'),
214
- multiple: spec.type === 'array',
221
+ type: spec.type === 'number' || isArrayOption(spec) ? 'string' : (spec.type || 'boolean'),
222
+ multiple: isArrayOption(spec),
215
223
  };
216
224
  args = args.map((arg) => {
217
225
  if (/^-[^-]=/.test(arg)) return `--${arg.slice(1)}`;
package/src/cli.js CHANGED
@@ -36,7 +36,7 @@ import { platformCmd } from './commands/platform.js';
36
36
  import { codesearchCmd } from './commands/codesearch.js';
37
37
  import { versionCmd } from './commands/version.js';
38
38
  import { skillCmd, checkSkill } from './commands/skill.js';
39
- import { getVersion, checkLatest } from './commands/version.js';
39
+ import { getVersion, checkLatest, getReleaseChannel, getInstallCommand } from './commands/version.js';
40
40
  import { docsCmd } from './commands/docs/docs.js';
41
41
 
42
42
  // Table and platform commands registered at root level for progressive disclosure
@@ -213,6 +213,7 @@ export function buildCLI() {
213
213
  hint: 'Run "jsn auth status" to list profiles.',
214
214
  });
215
215
  }
216
+ app._authArgv = argv;
216
217
  applySession(app, session);
217
218
  } catch (err) {
218
219
  guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
@@ -290,7 +291,7 @@ export function buildCLI() {
290
291
  }
291
292
  }
292
293
  if (!skipNpmCheck) {
293
- const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
294
+ const cacheFile = path.join(globalConfigDir(), `.last-npm-check-${getReleaseChannel()}`);
294
295
  let shouldCheck = true;
295
296
  try {
296
297
  const mtime = fs.statSync(cacheFile).mtimeMs;
@@ -303,9 +304,9 @@ export function buildCLI() {
303
304
 
304
305
  if (shouldCheck) {
305
306
  const version = getVersion();
306
- checkLatest({ background: true }).then(latest => {
307
+ checkLatest({ background: true, version }).then(latest => {
307
308
  if (latest && version !== latest) {
308
- process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
309
+ process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "${getInstallCommand(version)}" to update\n\n`);
309
310
  }
310
311
  }).catch(() => {});
311
312
 
@@ -1,6 +1,6 @@
1
1
  // Generic table-based CRUD command builder
2
2
 
3
- import { formatRecordForDisplay, getStringField, parseDataArg, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
3
+ import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
4
4
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
5
5
  import { getCurrentUser, getCurrentApplication } from '../context.js';
6
6
  import { FormatAuto } from '../output.js';
@@ -108,7 +108,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
108
108
  if (!readOnly) {
109
109
  crumbs.unshift(
110
110
  { action: 'delete', cmd: `jsn ${name} delete ${id}`, description: `Delete this ${singular}` },
111
- { action: 'update', cmd: `jsn ${name} update ${id} --data '{...}'`, description: `Update this ${singular}` }
111
+ { action: 'update', cmd: `jsn ${name} update ${id} -f name=value`, description: `Update this ${singular}` }
112
112
  );
113
113
  }
114
114
  return crumbs;
@@ -152,7 +152,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
152
152
  app.ok(record, {
153
153
  summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
154
154
  breadcrumbs: [
155
- ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} --data '{...}'`, description: `Update this ${singular}` }]),
155
+ ...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} -f name=value`, description: `Update this ${singular}` }]),
156
156
  { action: 'list', cmd: `jsn ${name} list`, description: `Back to all ${name}` },
157
157
  ],
158
158
  });
@@ -215,10 +215,12 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
215
215
  command: 'create',
216
216
  describe: `Create a new ${singular}`,
217
217
  builder: (y) => y
218
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
219
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
218
+ .option('data', { type: 'string', describe: 'JSON fields' })
219
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
220
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
221
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
220
222
  handler: wrap(async (argv, app) => {
221
- const recordData = parseDataArg(argv);
223
+ const recordData = buildRecordPayload(argv);
222
224
  const record = await app.sdk.create(table, recordData);
223
225
  app.ok(record, {
224
226
  summary: `Created ${singular}`,
@@ -232,8 +234,10 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
232
234
  command: 'update <identifier>',
233
235
  describe: `Update ${vowelArticle(singular)} ${singular}`,
234
236
  builder: (y) => y
235
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
236
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
237
+ .option('data', { type: 'string', describe: 'JSON fields' })
238
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
239
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
240
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
237
241
  handler: wrap(async (argv, app) => {
238
242
  const id = argv.identifier;
239
243
  const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
@@ -242,7 +246,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
242
246
  }
243
247
 
244
248
  const sysID = unwrapSysId(record);
245
- const recordData = parseDataArg(argv);
249
+ const recordData = buildRecordPayload(argv);
246
250
  const updated = await app.sdk.update(table, sysID, recordData);
247
251
  app.ok(updated, { summary: `Updated ${singular} ${id}` });
248
252
  }),
@@ -1,7 +1,7 @@
1
1
  // Generic command builder for CRUD operations on a ServiceNow table
2
2
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
3
 
4
- import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
5
5
  import { resolveRecord, resolveSysId } from '../resolve-record.js';
6
6
  import { FormatAuto } from '../output.js';
7
7
  import { declareCapabilities } from '../capabilities.js';
@@ -73,7 +73,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
73
73
  app.ok(record, {
74
74
  summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
75
75
  breadcrumbs: [
76
- { action: 'update', cmd: `jsn ${alias} update ${number} --data '{...}'`, description: `Update this ${displayName}` },
76
+ { action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
77
77
  { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
78
78
  ],
79
79
  });
@@ -139,7 +139,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
139
139
  app.ok(record, {
140
140
  summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
141
141
  breadcrumbs: [
142
- { action: 'update', cmd: `jsn ${alias} update ${number} --data '{...}'`, description: `Update this ${displayName}` },
142
+ { action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
143
143
  { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
144
144
  ],
145
145
  });
@@ -151,16 +151,16 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
151
151
  builder: (y) => y
152
152
  .option('description', { alias: 'd', type: 'string', describe: 'Short description' })
153
153
  .option('priority', { type: 'string', describe: 'Priority (1-5)' })
154
- .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
154
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' })
155
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
156
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
157
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
155
158
  handler: wrap(async (argv, app) => {
156
- const recordData = {};
157
- if (argv.data) {
158
- Object.assign(recordData, JSON.parse(argv.data));
159
- }
159
+ const recordData = buildRecordPayload(argv, { required: false });
160
160
  if (argv.description) recordData.short_description = argv.description;
161
161
  if (argv.priority) recordData.priority = argv.priority;
162
162
  if (!recordData.short_description) {
163
- throw new Error('short_description is required (use --description or --data)');
163
+ throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
164
164
  }
165
165
  const record = await app.sdk.create(table, recordData);
166
166
  app.ok(record, {
@@ -175,10 +175,13 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
175
175
  command: 'update <number>',
176
176
  describe: `Update a ${displayName}`,
177
177
  builder: (y) => y
178
- .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
178
+ .option('data', { type: 'string', describe: 'JSON data to update' })
179
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
180
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
181
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
179
182
  handler: wrap(async (argv, app) => {
180
183
  const number = argv.number;
181
- const recordData = JSON.parse(argv.data);
184
+ const recordData = buildRecordPayload(argv);
182
185
  const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
183
186
  const updated = await app.sdk.update(table, sysID, recordData);
184
187
  app.ok(updated, {
@@ -382,13 +382,18 @@ export async function modifyProfile(app, argv = {}) {
382
382
  const name = argv.name || argv.profile || await pickProfile(app, 'Modify which profile?', argv);
383
383
  const profile = app.config.profiles?.[name];
384
384
  if (!profile) throw new Error(`Profile not found: ${name}`);
385
- const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts'].some(flag => argv[flag] !== undefined);
385
+ if (argv.reauth !== undefined && !['auto', 'confirm', 'never'].includes(argv.reauth)) {
386
+ throw errUsage('--reauth must be auto, confirm, or never.');
387
+ }
388
+ const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts', 'reauth'].some(flag => argv[flag] !== undefined);
386
389
  if (explicitSettings) {
387
390
  if (argv.domain !== undefined) throw errUsage('Set profile preferences and domain separately.');
388
391
  const updated = await configureProfileOptions({ ...profile }, argv);
392
+ if (argv.reauth !== undefined) updated.reauth = argv.reauth;
389
393
  app.config.profiles[name] = updated;
390
394
  await saveConfig(app.config);
391
- app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false },
395
+ app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false,
396
+ ...(argv.reauth !== undefined ? { reauth: updated.reauth } : {}) },
392
397
  { summary: `Settings saved for ${name}` });
393
398
  return;
394
399
  }
@@ -1134,6 +1139,7 @@ export function authCmd(wrap) {
1134
1139
  .option('read-only', { type: 'boolean', describe: 'Set read-only access (use --no-read-only to allow writes)' })
1135
1140
  .option('include-counts', { type: 'boolean', describe: 'Set result counts (use --no-include-counts to disable)' })
1136
1141
  .option('confirm-deletes', { type: 'boolean', describe: 'Set delete confirmations (use --no-confirm-deletes to disable)' })
1142
+ .option('reauth', { type: 'string', choices: ['auto', 'confirm', 'never'], describe: 'Browser recovery policy: auto, confirm (default), or never; interactive only' })
1137
1143
  .option('skip-confirmations', { type: 'boolean', describe: 'Compatibility flag: disable delete confirmations' })
1138
1144
  .positional('name', {
1139
1145
  describe: 'Profile name to modify',
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch, buildRecordPayload } from '../helpers.js';
2
2
  import { declareCapabilities } from '../capabilities.js';
3
3
 
4
4
  declareCapabilities('groups', { mutationSubcommands: ['create', 'update', 'delete'] });
@@ -94,12 +94,12 @@ export function groupsCmd(wrap) {
94
94
  builder: (y) => y
95
95
  .option('name', { alias: 'n', type: 'string', describe: 'Group name' })
96
96
  .option('manager', { type: 'string', describe: 'Manager name or sys_id' })
97
- .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
97
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' })
98
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
99
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
100
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
98
101
  handler: wrap(async (argv, app) => {
99
- const recordData = {};
100
- if (argv.data) {
101
- Object.assign(recordData, JSON.parse(argv.data));
102
- }
102
+ const recordData = buildRecordPayload(argv, { required: false });
103
103
  if (argv.name) recordData.name = argv.name;
104
104
  if (argv.manager) recordData.manager = argv.manager;
105
105
  if (!recordData.name) {
@@ -118,11 +118,14 @@ export function groupsCmd(wrap) {
118
118
  command: 'update <name>',
119
119
  describe: 'Update a group by name or sys_id',
120
120
  builder: (y) => y
121
- .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
121
+ .option('data', { type: 'string', describe: 'JSON data to update' })
122
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
123
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
124
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
122
125
  handler: wrap(async (argv, app) => {
123
126
  const id = argv.name;
124
127
  assertSafeExactMatch(id);
125
- const recordData = JSON.parse(argv.data);
128
+ const recordData = buildRecordPayload(argv);
126
129
  const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
127
130
  const params = new URLSearchParams();
128
131
  params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
3
+ import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, buildRecordPayload, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
 
6
6
  declareCapabilities('records', {
@@ -142,7 +142,7 @@ export function recordsCmd(wrap) {
142
142
  }
143
143
 
144
144
  const breadcrumbs = [
145
- { action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
145
+ { action: 'create', cmd: `jsn records create --table ${table} -f name=value`, description: 'Create a new record' },
146
146
  { action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
147
147
  { action: 'columns', cmd: `jsn columns --table ${table}`, description: 'View available columns' },
148
148
  ];
@@ -221,12 +221,13 @@ export function recordsCmd(wrap) {
221
221
  describe: 'Create a new record',
222
222
  builder: (y) => y
223
223
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
224
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
224
+ .option('data', { type: 'string', describe: 'JSON fields' })
225
225
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
226
226
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
227
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
227
228
  .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
228
229
  handler: wrap(async (argv, app) => {
229
- const recordData = parseDataArg(argv);
230
+ const recordData = buildRecordPayload(argv);
230
231
  const warnings = checkDerivedFields(argv.table, recordData);
231
232
  for (const w of warnings) {
232
233
  process.stderr.write(`⚠ ${w.hint}\n`);
@@ -249,12 +250,13 @@ export function recordsCmd(wrap) {
249
250
  builder: (y) => y
250
251
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
251
252
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
252
- .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
253
+ .option('data', { type: 'string', describe: 'JSON fields' })
253
254
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
254
255
  .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
256
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
255
257
  .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
256
258
  handler: wrap(async (argv, app) => {
257
- const recordData = parseDataArg(argv);
259
+ const recordData = buildRecordPayload(argv);
258
260
  const warnings = checkDerivedFields(argv.table, recordData);
259
261
  for (const w of warnings) {
260
262
  process.stderr.write(`⚠ ${w.hint}\n`);
@@ -401,22 +403,15 @@ export function recordsCmd(wrap) {
401
403
  builder: (y) => y
402
404
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
403
405
  .option('query', { type: 'string', demandOption: true, describe: 'Encoded query selecting the records to update (e.g. "priority=1^state=1")' })
404
- .option('set', { type: 'string', demandOption: true, describe: 'JSON object of fields to set (e.g. \'{"state":"3"}\')' })
406
+ .option('set', { type: 'string', describe: 'JSON object of fields to set' })
407
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' })
405
408
  .option('dry-run', { type: 'boolean', default: true, describe: 'Preview the count + a sample without mutating (default)' })
406
409
  .option('execute', { type: 'boolean', default: false, describe: 'Perform the update after confirmation' })
407
410
  .option('limit', { type: 'number', default: 200, describe: 'Max records to update when executing' })
408
411
  .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation (with --execute)' }),
409
412
  handler: wrap(async (argv, app) => {
410
413
  app.requireInstance();
411
- let set;
412
- try {
413
- set = JSON.parse(argv.set);
414
- } catch (e) {
415
- throw new Error(`Invalid --set JSON: ${e.message}`, { cause: e });
416
- }
417
- if (!set || typeof set !== 'object' || Array.isArray(set)) {
418
- throw new Error('--set must be a JSON object of field=value pairs');
419
- }
414
+ const set = buildRecordPayload({ ...argv, data: argv.set }, { required: true });
420
415
  if (!argv['dry-run'] && !argv.execute) {
421
416
  argv.execute = true; // explicit --no-dry-run implies execute intent
422
417
  }
@@ -53,7 +53,7 @@ async function enrichedShowHandler(argv, app) {
53
53
  app.ok(record, {
54
54
  summary: `Request ${number}`,
55
55
  breadcrumbs: [
56
- { action: 'update', cmd: `jsn req update ${number} --data '{...}'`, description: 'Update this request' },
56
+ { action: 'update', cmd: `jsn req update ${number} -f state=2`, description: 'Update this request' },
57
57
  { action: 'list', cmd: `jsn req list`, description: 'Back to all requests' },
58
58
  ],
59
59
  });
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
2
2
  import { resolveRecord, resolveSysId } from '../resolve-record.js';
3
3
  import { declareCapabilities } from '../capabilities.js';
4
4
 
@@ -33,7 +33,7 @@ export function ticketsCmd(wrap) {
33
33
  const records = await app.sdk.list('task', params);
34
34
  const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
35
35
  const breadcrumbs = [
36
- { action: 'create', cmd: 'jsn tickets create --data \'{...}\'', description: 'Create a new ticket' },
36
+ { action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
37
37
  ];
38
38
  if (records.length === argv.limit) {
39
39
  breadcrumbs.push({
@@ -71,9 +71,13 @@ export function ticketsCmd(wrap) {
71
71
  .command({
72
72
  command: 'create',
73
73
  describe: 'Create a new ticket',
74
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
74
+ builder: (y) => y
75
+ .option('data', { type: 'string', describe: 'JSON fields' })
76
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
77
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
78
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
75
79
  handler: wrap(async (argv, app) => {
76
- const recordData = JSON.parse(argv.data);
80
+ const recordData = buildRecordPayload(argv);
77
81
  const record = await app.sdk.create('task', recordData);
78
82
  app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
79
83
  }),
@@ -81,9 +85,13 @@ export function ticketsCmd(wrap) {
81
85
  .command({
82
86
  command: 'update <number>',
83
87
  describe: 'Update a ticket',
84
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
88
+ builder: (y) => y
89
+ .option('data', { type: 'string', describe: 'JSON fields' })
90
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
91
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
92
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
85
93
  handler: wrap(async (argv, app) => {
86
- const recordData = JSON.parse(argv.data);
94
+ const recordData = buildRecordPayload(argv);
87
95
  const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
88
96
  const updated = await app.sdk.update('task', sysID, recordData);
89
97
  app.ok(updated, { summary: `Updated ticket ${argv.number}` });
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
2
2
  import { resolveRecord, resolveSysId } from '../resolve-record.js';
3
3
  import { declareCapabilities } from '../capabilities.js';
4
4
 
@@ -72,12 +72,12 @@ export function usersCmd(wrap) {
72
72
  .option('username', { alias: 'u', type: 'string', describe: 'Username (e.g. john.doe)' })
73
73
  .option('name', { alias: 'n', type: 'string', describe: 'Full name (e.g. John Doe)' })
74
74
  .option('email', { alias: 'e', type: 'string', describe: 'Email address' })
75
- .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
75
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' })
76
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
77
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
78
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
76
79
  handler: wrap(async (argv, app) => {
77
- const recordData = {};
78
- if (argv.data) {
79
- Object.assign(recordData, JSON.parse(argv.data));
80
- }
80
+ const recordData = buildRecordPayload(argv, { required: false });
81
81
  if (argv.username) recordData.user_name = argv.username;
82
82
  if (argv.name) recordData.name = argv.name;
83
83
  if (argv.email) recordData.email = argv.email;
@@ -97,10 +97,13 @@ export function usersCmd(wrap) {
97
97
  command: 'update <identifier>',
98
98
  describe: 'Update a user by user_name or sys_id',
99
99
  builder: (y) => y
100
- .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
100
+ .option('data', { type: 'string', describe: 'JSON data to update' })
101
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
102
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
103
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
101
104
  handler: wrap(async (argv, app) => {
102
105
  const id = argv.identifier;
103
- const recordData = JSON.parse(argv.data);
106
+ const recordData = buildRecordPayload(argv);
104
107
  const sysID = await resolveSysId(app.sdk, { table: 'sys_user', identifier: id, matchField: 'user_name', resource: 'User' });
105
108
  const updated = await app.sdk.update('sys_user', sysID, recordData);
106
109
  app.ok(updated, {
@@ -6,7 +6,13 @@ import { declareCapabilities } from '../capabilities.js';
6
6
  declareCapabilities('version', { noInstance: true, skipDailyChecks: true });
7
7
 
8
8
  const NPM_PKG = '@jacebenson/jsn';
9
- const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
9
+ export function getReleaseChannel(version = getVersion()) {
10
+ return /^\d+\.\d+\.\d+-dev\./.test(version) ? 'dev' : 'latest';
11
+ }
12
+
13
+ export function getInstallCommand(version = getVersion()) {
14
+ return `npm install -g ${NPM_PKG}${getReleaseChannel(version) === 'dev' ? '@dev' : ''}`;
15
+ }
10
16
 
11
17
  export function getVersion() {
12
18
  try {
@@ -26,16 +32,16 @@ export function cancelBackgroundChecks() {
26
32
  backgroundChecks.clear();
27
33
  }
28
34
 
29
- export async function checkLatest({ timeout = 1500, background = false } = {}) {
35
+ export async function checkLatest({ timeout = 1500, background = false, version = getVersion() } = {}) {
30
36
  const controller = new AbortController();
31
37
  const timer = setTimeout(() => controller.abort(), timeout);
32
38
  timer.unref();
33
39
  if (background) backgroundChecks.add(controller);
34
40
  try {
35
- const res = await fetch(NPM_REGISTRY, { signal: controller.signal });
41
+ const res = await fetch(`https://registry.npmjs.org/${NPM_PKG}/${getReleaseChannel(version)}`, { signal: controller.signal });
36
42
  if (!res.ok) return null;
37
43
  const data = await res.json();
38
- return data.version;
44
+ return typeof data.version === 'string' ? data.version : null;
39
45
  } catch {
40
46
  return null;
41
47
  } finally {
@@ -58,11 +64,11 @@ export function versionCmd(wrap) {
58
64
  const version = getVersion();
59
65
 
60
66
  if (argv.check) {
61
- const latest = await checkLatest();
67
+ const latest = await checkLatest({ version });
62
68
  if (!latest) {
63
69
  app.ok({ version }, {
64
70
  summary: `jsn ${version} (could not reach npm registry to check for updates)`,
65
- notice: `Run \`npm view ${NPM_PKG}\` directly to check manually`,
71
+ notice: `Run \`npm view ${NPM_PKG}@${getReleaseChannel(version)} version\` directly to check manually`,
66
72
  });
67
73
  return;
68
74
  }
@@ -82,11 +88,11 @@ export function versionCmd(wrap) {
82
88
  version,
83
89
  latest,
84
90
  upToDate: false,
85
- installCmd: `npm install -g ${NPM_PKG}`,
91
+ installCmd: getInstallCommand(version),
86
92
  }, {
87
93
  summary: `jsn ${version} — newer version ${latest} available`,
88
94
  breadcrumbs: [
89
- { action: 'update', cmd: `npm install -g ${NPM_PKG}`, description: `Update jsn from ${version} to ${latest}` },
95
+ { action: 'update', cmd: getInstallCommand(version), description: `Update jsn from ${version} to ${latest}` },
90
96
  ],
91
97
  });
92
98
  }
package/src/errors.js CHANGED
@@ -77,6 +77,16 @@ export function errNetwork(cause) {
77
77
  }
78
78
 
79
79
  export function errAPI(status, msg) {
80
+ // Authentication/authorization bodies can echo credentials. Never render them.
81
+ if (status === 401) {
82
+ const error = new AppError(CodeAuth, 'Authentication rejected for the active profile (HTTP 401).', 'Run: jsn auth login', 401);
83
+ error.recovery = 'jsn auth login';
84
+ return error;
85
+ }
86
+ if (status === 403) {
87
+ return new AppError(CodeForbidden, 'Access forbidden (HTTP 403).',
88
+ 'This is an authorization/ACL/role problem, not a login problem. Check access to this endpoint.', 403);
89
+ }
80
90
  let hint = 'Check the API documentation for this endpoint.';
81
91
  if (status >= 500) {
82
92
  hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
@@ -229,6 +239,11 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
229
239
  return { stream: 'stderr', text, exitCode: 1 };
230
240
  }
231
241
 
242
+ if (code === CodeForbidden) {
243
+ if (resolveJsonMode(app, argv) && app) return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
244
+ return { stream: 'stderr', text: `Error (forbidden): ${err.message}\n\nHint: ${err.hint}\n`, exitCode: 1 };
245
+ }
246
+
232
247
  if (code === CodeConfirmationRequired) {
233
248
  // Structured error for AI agents / piped consumers: the JSON envelope
234
249
  // goes to stdout (where tooling reads) so the agent can surface the
package/src/helpers.js CHANGED
@@ -522,6 +522,50 @@ export function parseDataArg(argv) {
522
522
  }
523
523
  }
524
524
 
525
+ /**
526
+ * Parse repeatable -f/--field name=value options.
527
+ * Values remain strings, including empty values and values containing '='.
528
+ */
529
+ export function parseFieldPairs(argv = {}) {
530
+ const raw = argv.field == null ? [] : (Array.isArray(argv.field) ? argv.field : [argv.field]);
531
+ const result = {};
532
+ for (const token of raw) {
533
+ const text = String(token);
534
+ const equals = text.indexOf('=');
535
+ if (equals < 0) throw new Error(`-f expects field=value, got "${text}"`);
536
+ const name = text.slice(0, equals);
537
+ let value = text.slice(equals + 1);
538
+ if (!name || /\\s/.test(name) || !/^[A-Za-z0-9_.]+$/.test(name)) {
539
+ throw new Error(`-f expects a valid field name, got "${name}"`);
540
+ }
541
+ if (value.startsWith('@@')) {
542
+ value = value.slice(1);
543
+ } else if (value.startsWith('@')) {
544
+ value = fs.readFileSync(value.slice(1), 'utf-8');
545
+ if (value.charCodeAt(0) === 0xFEFF) value = value.slice(1);
546
+ }
547
+ result[name] = value;
548
+ }
549
+ return result;
550
+ }
551
+
552
+ /**
553
+ * Build a record mutation payload from JSON input and/or field pairs.
554
+ */
555
+ export function buildRecordPayload(argv = {}, { required = true } = {}) {
556
+ const hasData = Boolean(argv.data || argv['data-file'] || argv['data-stdin']);
557
+ const hasFields = argv.field != null && (Array.isArray(argv.field) ? argv.field.length > 0 : true);
558
+ if (!hasData && !hasFields) {
559
+ if (required) throw new Error('Provide fields with -f name=value, or a JSON payload with --data/--data-file/--data-stdin');
560
+ return {};
561
+ }
562
+ const payload = hasData ? parseDataArg(argv) : {};
563
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
564
+ throw new Error('Record payload must be a JSON object');
565
+ }
566
+ return { ...payload, ...parseFieldPairs(argv) };
567
+ }
568
+
525
569
  /**
526
570
  * Translate human-readable type names to ServiceNow item_option_new type IDs.
527
571
  * Maps common names like "date", "select", "multilinetext" to their integer IDs.
package/src/output.js CHANGED
@@ -160,6 +160,11 @@ export class OutputWriter {
160
160
  const format = this.effectiveFormat();
161
161
  const e = error.code ? error : { code: 'unknown', message: String(error), hint: '' };
162
162
  const resp = { ok: false, error: e.message, code: e.code, hint: e.hint || '' };
163
+ if (e.status === 401 || e.status === 403) resp.status = e.status;
164
+ if (e.recovery) {
165
+ resp.recovery = e.recovery;
166
+ if (Object.hasOwn(e, 'profile')) resp.profile = e.profile;
167
+ }
163
168
  switch (format) {
164
169
  case FormatJSON:
165
170
  case FormatQuiet:
package/src/sdk.js CHANGED
@@ -47,6 +47,7 @@ export class SDKClient {
47
47
  constructor(baseURL, authProvider, opts = {}) {
48
48
  this.baseURL = baseURL.replace(/\/$/, '');
49
49
  this.authProvider = authProvider;
50
+ this.recovery = opts.recovery;
50
51
  this.timeout = opts.timeout || DEFAULT_TIMEOUT;
51
52
  this.domain = opts.domain || '';
52
53
  this.oauthCookies = null;
@@ -100,23 +101,42 @@ export class SDKClient {
100
101
  async _withAuthenticatedRequest(endpoint, opts, {
101
102
  timeout = this.timeout, prepare, skipOAuthSession = false, normalizeErrors = false,
102
103
  }, consume = (response) => response) {
103
- const controller = new AbortController();
104
- const timer = setTimeout(() => controller.abort(), timeout);
105
- try {
106
- const req = new Request(endpoint, { ...opts, signal: controller.signal });
107
- if (prepare) prepare(req);
108
- await this._setAuth(req, { skipOAuthSession });
109
- return await consume(await fetch(req));
110
- } catch (err) {
111
- if (normalizeErrors) {
112
- if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
113
- if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
114
- throw errNetwork(err);
104
+ for (let attempt = 0; attempt < 2; attempt++) {
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), timeout);
107
+ let rejection;
108
+ try {
109
+ const req = new Request(endpoint, { ...opts, signal: controller.signal });
110
+ if (prepare) prepare(req);
111
+ await this._setAuth(req, { skipOAuthSession });
112
+ const response = await fetch(req);
113
+ if (!skipOAuthSession && response.status === 401 && this.recovery) {
114
+ // Only a definitive HTTP rejection permits replay, including POST.
115
+ // Do not consume/echo the provider's potentially sensitive body.
116
+ rejection = { status: response.status, method: req.method, url: req.url };
117
+ await response.body?.cancel();
118
+ } else {
119
+ return await consume(response);
115
120
  }
121
+ } catch (err) {
122
+ if (normalizeErrors) {
123
+ if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
124
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
125
+ throw errNetwork(err);
126
+ }
127
+ }
128
+ throw err;
129
+ } finally {
130
+ clearTimeout(timer);
116
131
  }
117
- throw err;
118
- } finally {
119
- clearTimeout(timer);
132
+ // Browser renewal has its own existing hard timeout. Never leave a
133
+ // request timeout running while waiting for the human to authenticate.
134
+ if (attempt === 0 && await this.recovery.recover(rejection)) {
135
+ this.oauthCookies = null;
136
+ this.sessionUserToken = '';
137
+ continue;
138
+ }
139
+ throw this.recovery.error || errAPI(401, '');
120
140
  }
121
141
  }
122
142