@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 +41 -0
- package/package.json +1 -1
- package/src/app.js +5 -1
- package/src/auth-recovery.js +74 -0
- package/src/cli-adapter.js +12 -4
- package/src/cli.js +5 -4
- package/src/commands/_generic.js +13 -9
- package/src/commands/_ticket.js +14 -11
- package/src/commands/auth.js +8 -2
- package/src/commands/groups.js +11 -8
- package/src/commands/records.js +11 -16
- package/src/commands/requests.js +1 -1
- package/src/commands/tickets.js +14 -6
- package/src/commands/users.js +11 -8
- package/src/commands/version.js +14 -8
- package/src/errors.js +15 -0
- package/src/helpers.js +44 -0
- package/src/output.js +5 -0
- package/src/sdk.js +35 -15
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
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
|
+
}
|
package/src/cli-adapter.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
214
|
-
multiple: spec
|
|
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(),
|
|
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 "
|
|
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
|
|
package/src/commands/_generic.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generic table-based CRUD command builder
|
|
2
2
|
|
|
3
|
-
import { formatRecordForDisplay, getStringField,
|
|
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}
|
|
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}
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
}),
|
package/src/commands/_ticket.js
CHANGED
|
@@ -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}
|
|
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}
|
|
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',
|
|
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 =
|
|
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, {
|
package/src/commands/auth.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
package/src/commands/groups.js
CHANGED
|
@@ -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',
|
|
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 =
|
|
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}`);
|
package/src/commands/records.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { formatCommandList, formatRecordForDisplay, buildQuerySuffix,
|
|
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}
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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',
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/requests.js
CHANGED
|
@@ -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}
|
|
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
|
});
|
package/src/commands/tickets.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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}` });
|
package/src/commands/users.js
CHANGED
|
@@ -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',
|
|
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 =
|
|
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, {
|
package/src/commands/version.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
91
|
+
installCmd: getInstallCommand(version),
|
|
86
92
|
}, {
|
|
87
93
|
summary: `jsn ${version} — newer version ${latest} available`,
|
|
88
94
|
breadcrumbs: [
|
|
89
|
-
{ action: 'update', cmd:
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|