@jacebenson/jsn 4.3.11 → 4.3.12-dev.70

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/src/auth.js CHANGED
@@ -5,76 +5,20 @@
5
5
  // same instance have separate credential slots.
6
6
 
7
7
  import fs from 'node:fs';
8
- import path from 'node:path';
9
- import crypto from 'node:crypto';
10
- import { execFileSync } from 'node:child_process';
11
8
  import { password as passwordPrompt } from './prompts.js';
12
- import { globalConfigDir, normalizeInstanceURL } from './config.js';
9
+ import { normalizeInstanceURL } from './config.js';
13
10
  import { errAuth } from './errors.js';
14
11
  import { pendingBrowserLogin } from './browser-auth.js';
12
+ import { defaultCredentialStore } from './auth/credential-store.js';
13
+ import { savePKCEState, loadPKCEState, removePKCEState, getOAuthClientID, generatePKCE, buildAuthURL, REDIRECT_URI } from './auth/oauth.js';
14
+ import { parseBrowserSessionInput } from './auth/browser-session.js';
15
+ import { normalizeAuthMethod, normalizeAuthSource, sourceMethod, probeFailure, classifyProbeError, classifyCredentialState } from './auth/credential-state.js';
15
16
 
16
- // ─── Credential key helpers ───
17
-
18
- /**
19
- * Sanitize an instance URL into a filesystem/arg-safe key part.
20
- *
21
- * Whitelist approach (issue #143 findings #2/#3): first map the `://`
22
- * protocol separator to `_` exactly as the Go version's encoding does
23
- * (so existing keyring keys like `https_dev437538.service-now.com` keep
24
- * matching), then drop everything outside [a-zA-Z0-9._-]. Blacklist
25
- * stripping of just `/` and `:` left quotes, `$()`, backticks, `;`,
26
- * spaces, and backslashes — which broke out of double-quoted shell args
27
- * in keyring calls and, on Windows, out of path.join() credential dirs.
28
- */
29
- export function sanitizeKeyPart(instance) {
30
- return instance.replace(/:\/\//g, '_').replace(/[^a-zA-Z0-9._-]/g, '_');
31
- }
32
-
33
- /**
34
- * Build a compound key for credential storage: <username>@<instance>
35
- * When username is omitted (legacy), just the normalized instance URL is used.
36
- */
37
- function credKey(instance, username) {
38
- const normalized = sanitizeKeyPart(instance);
39
- if (!username) return normalized;
40
- return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
41
- }
42
-
43
- // ─── PKCE state persistence (shared with Go version) ───
44
-
45
- function pkceStatePath(instance, username) {
46
- const dir = path.join(globalConfigDir(), 'pkce');
47
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
- // A known profile identity gets its own pending-login slot. Keep the bare
49
- // instance slot for legacy/no-profile flows, where no identity exists yet.
50
- const filename = credKey(instance, username) + '.json';
51
- return path.join(dir, filename);
52
- }
53
-
54
- function savePKCEState(instance, pkce, username) {
55
- const filePath = pkceStatePath(instance, username);
56
- fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
57
- }
58
-
59
- function loadPKCEState(instance, username) {
60
- try {
61
- const data = fs.readFileSync(pkceStatePath(instance, username), 'utf-8');
62
- return JSON.parse(data);
63
- } catch {
64
- return null;
65
- }
66
- }
67
-
68
- function removePKCEState(instance, username) {
69
- try {
70
- fs.unlinkSync(pkceStatePath(instance, username));
71
- } catch {
72
- // ignore
73
- }
74
- }
17
+ // Preserve the public auth module API while implementation stays separated.
18
+ export { sanitizeKeyPart } from './auth/credential-store.js';
19
+ export { parseBrowserSessionInput } from './auth/browser-session.js';
20
+ export { classifyCredentialState } from './auth/credential-state.js';
75
21
 
76
- const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
77
- const REDIRECT_URI = '/sdk-oauth.do';
78
22
 
79
23
  // ─── Basic auth from environment variables ───
80
24
  // SN_USERNAME / SN_PASSWORD — global credentials
@@ -110,322 +54,10 @@ function getBasicAuthFromEnv(instance) {
110
54
  return null;
111
55
  }
112
56
 
113
- // Keychain constants — same as Go version (internal/auth/store.go)
114
- const KEYRING_SERVICE = 'servicenow-cli';
115
- const KEYRING_ATTR_SERVICE = 'service';
116
- const KEYRING_ATTR_USERNAME = 'username';
117
-
118
- function credentialsFilePath(key) {
119
- const dir = path.join(globalConfigDir(), 'credentials');
120
- // Match Go's filename encoding: replace :// and / and : with _
121
- const filename = key + '.json';
122
- return path.join(dir, filename);
123
- }
124
-
125
- function credentialsPath(key) {
126
- const filePath = credentialsFilePath(key);
127
- fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
128
- return filePath;
129
- }
130
-
131
- function getOAuthClientID() {
132
- return process.env.SERVICENOW_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
133
- }
134
-
135
- function generatePKCE() {
136
- const verifier = crypto.randomBytes(32).toString('base64url');
137
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
138
- const state = crypto.randomBytes(16).toString('base64url');
139
- return { code_verifier: verifier, code_challenge: challenge, state };
140
- }
141
-
142
- function buildAuthURL(instanceURL, clientID, pkce) {
143
- const u = new URL('/oauth_auth.do', instanceURL);
144
- u.searchParams.set('response_type', 'code');
145
- u.searchParams.set('client_id', clientID);
146
- u.searchParams.set('redirect_uri', REDIRECT_URI);
147
- u.searchParams.set('state', pkce.state);
148
- u.searchParams.set('code_challenge', pkce.code_challenge);
149
- u.searchParams.set('code_challenge_method', 'S256');
150
- u.searchParams.set('scope', 'openid');
151
- u.searchParams.set('approval_prompt', 'force');
152
- return u.toString();
153
- }
154
-
155
- // ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
156
-
157
- function keyringLookup(key) {
158
- try {
159
- // execFileSync with an arg array — no shell, so `key` can never
160
- // break out into command injection (issue #143 finding #2).
161
- const result = execFileSync(
162
- 'secret-tool',
163
- ['lookup', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
164
- { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
165
- );
166
- const trimmed = result.trim();
167
- if (!trimmed) return null;
168
- const parsed = JSON.parse(trimmed);
169
- // Spread all fields from keyring first, then normalize known fields
170
- // so username, password, and other extras are preserved.
171
- return {
172
- ...parsed,
173
- auth_method: parsed.auth_method || 'oauth',
174
- access_token: parsed.access_token || parsed.AccessToken || '',
175
- refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
176
- expires_at: parsed.expires_at || parsed.ExpiresAt || 0,
177
- created_at: parsed.created_at || parsed.CreatedAt || 0,
178
- auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(parsed.auth_source)
179
- ? (parsed.auth_method === 'gck' ? 'gck' : 'keyring')
180
- : parsed.auth_source,
181
- };
182
- } catch {
183
- return null;
184
- }
185
- }
186
-
187
- function keyringStore(key, creds) {
188
- try {
189
- execFileSync(
190
- 'secret-tool',
191
- ['store', `--label=Password for '${key}' on '${KEYRING_SERVICE}'`,
192
- KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
193
- { stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
194
- );
195
- return true;
196
- } catch {
197
- return false;
198
- }
199
- }
200
-
201
- function keyringDelete(key) {
202
- try {
203
- execFileSync(
204
- 'secret-tool',
205
- ['clear', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
206
- { stdio: 'ignore' }
207
- );
208
- } catch {
209
- // ignore
210
- }
211
- }
212
-
213
- // ─── File-based storage (fallback) ───
214
-
215
- /**
216
- * Load credentials for an instance keyed by <username>@<instance>.
217
- * When username is omitted (legacy configs without profile user), uses
218
- * bare instance key for lookup.
219
- */
220
- function loadCredentials(instance, username, injectedStore) {
221
- if (injectedStore) return injectedStore.load(instance, username);
222
- const key = username ? credKey(instance, username) : credKey(instance);
223
- const keyringCreds = keyringLookup(key);
224
- if (keyringCreds) {
225
- return {
226
- ...keyringCreds,
227
- auth_source: keyringCreds.auth_source || (keyringCreds.auth_method === 'gck' ? 'gck' : 'keyring'),
228
- };
229
- }
230
- try {
231
- const data = fs.readFileSync(credentialsFilePath(key), 'utf-8');
232
- const creds = JSON.parse(data);
233
- return {
234
- ...creds,
235
- auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(creds.auth_source)
236
- ? (creds.auth_method === 'gck' ? 'gck' : 'file')
237
- : creds.auth_source,
238
- };
239
- } catch {
240
- return null;
241
- }
242
- }
243
-
244
- /**
245
- * Save credentials for an instance.
246
- * Uses <username>@<instance> key when username is available.
247
- */
248
- function saveCredentials(instance, creds, username, injectedStore) {
249
- if (injectedStore) {
250
- creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
251
- return injectedStore.save(instance, creds, username);
252
- }
253
- const key = username ? credKey(instance, username) : credKey(instance);
254
- // Stamp last_seen on every credential save
255
- creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
256
- // Try keyring first, fall back to file
257
- if (!keyringStore(key, creds)) {
258
- fs.writeFileSync(credentialsPath(key), JSON.stringify(creds, null, 2), { mode: 0o600 });
259
- }
260
- }
261
-
262
- /**
263
- * Delete credentials for an instance keyed by <username>@<instance>.
264
- */
265
- function deleteCredentials(instance, username, injectedStore) {
266
- if (injectedStore) return injectedStore.delete(instance, username);
267
- const key = username ? credKey(instance, username) : credKey(instance);
268
- keyringDelete(key);
269
- try {
270
- fs.unlinkSync(credentialsPath(key));
271
- } catch {
272
- // ignore
273
- }
274
- }
275
-
276
- const defaultCredentialStore = {
277
- load: loadCredentials,
278
- save: saveCredentials,
279
- delete: deleteCredentials,
280
- };
281
-
282
57
  function askHidden(promptText) {
283
58
  return passwordPrompt({ message: promptText });
284
59
  }
285
60
 
286
- /**
287
- * Extract the browser session token and cookies from pasted DevTools headers
288
- * or a "Copy as cURL" command. Only these two values are retained.
289
- */
290
- export function parseBrowserSessionInput(input) {
291
- const text = String(input || '');
292
- const tokenMatch = text.match(/(?:x-usertoken|x-user-token)\s*:\s*([^\s'";,]+)/i);
293
- const cookieArgMatch = text.match(/(?:^|\s)(?:-b|--cookie)\s+(['"])(.*?)\1/s);
294
- const quotedCookieMatch = text.match(/cookie\s*:\s*(['"])(.*?)\1/i);
295
- const plainCookieMatch = text.match(/cookie\s*:\s*([^\r\n]+)/i);
296
- const token = tokenMatch?.[1]?.trim() || '';
297
- const cookies = (cookieArgMatch?.[2] || quotedCookieMatch?.[2] || plainCookieMatch?.[1] || '')
298
- .trim()
299
- .replace(/['"`]\s*(?:\\\s*)?$/, '');
300
- if (!token) throw errAuth('No X-UserToken header found');
301
- if (!cookies) throw errAuth('No Cookie header found. Paste the complete request headers or cURL command.');
302
- return { auth_method: 'gck', access_token: token, cookies, auth_source: 'gck' };
303
- }
304
-
305
- const AUTH_METHODS = new Set(['oauth', 'basic', 'gck']);
306
- const AUTH_SOURCES = new Set(['env_token', 'env_basic', 'gck', 'keyring', 'file', 'unavailable']);
307
-
308
- function normalizeAuthMethod(value, fallback = 'unconfigured') {
309
- return AUTH_METHODS.has(value) ? value : fallback;
310
- }
311
-
312
- function normalizeAuthSource(value, fallback = 'unavailable') {
313
- return AUTH_SOURCES.has(value) ? value : fallback;
314
- }
315
-
316
- function sourceMethod(source) {
317
- return { env_token: 'oauth', env_basic: 'basic', gck: 'gck' }[source] || null;
318
- }
319
-
320
- const PROBE_FAILURES = {
321
- missing_credentials: {
322
- message: 'Credentials are not available for this profile.',
323
- hint: 'Run: jsn auth login',
324
- },
325
- credentials_expired: {
326
- message: 'Credentials have expired and cannot be refreshed.',
327
- hint: 'Run: jsn auth login',
328
- },
329
- refresh_required: {
330
- message: 'Credentials require refresh before probing.',
331
- hint: 'Run: jsn auth refresh',
332
- },
333
- refresh_failed: {
334
- message: 'Credential refresh failed before the authenticated probe.',
335
- hint: 'Run: jsn auth login',
336
- },
337
- invalid_browser_session: {
338
- message: 'Browser session credentials are incomplete or invalid.',
339
- hint: 'Capture a fresh ServiceNow browser request and run: jsn auth login --gck',
340
- },
341
- malformed_credentials: {
342
- message: 'Credentials are malformed for the selected authentication method.',
343
- hint: 'Run: jsn auth login',
344
- },
345
- permission_denied: {
346
- message: 'The authenticated user lacks permission for the current-user probe.',
347
- hint: 'Ask an administrator to grant access to the sys_user table/API.',
348
- },
349
- unauthorized: {
350
- message: 'The instance rejected the selected credentials.',
351
- hint: 'Run: jsn auth login',
352
- },
353
- network: {
354
- message: 'The authenticated probe could not reach the instance.',
355
- hint: 'Check your internet connection and instance URL.',
356
- },
357
- unknown: {
358
- message: 'The authenticated probe failed for an unknown reason.',
359
- hint: 'Retry the probe; if it persists, check the instance logs.',
360
- },
361
- missing_instance: {
362
- message: 'No instance is configured for this profile.',
363
- hint: 'Set an instance URL and run: jsn auth login',
364
- },
365
- sdk_construction_failed: {
366
- message: 'The authenticated probe could not be initialized for this profile.',
367
- hint: 'Check the profile configuration and run: jsn auth status again',
368
- },
369
- };
370
-
371
- const PROBE_CLASSIFICATIONS = {
372
- missing_credentials: 'unavailable', credentials_expired: 'expired', refresh_required: 'refresh_required',
373
- refresh_failed: 'refresh_failed', invalid_browser_session: 'browser_session_invalid',
374
- malformed_credentials: 'malformed', permission_denied: 'permission_denied', unauthorized: 'unauthorized',
375
- network: 'network_error', unknown: 'unknown',
376
- missing_instance: 'unavailable', sdk_construction_failed: 'configuration_error',
377
- };
378
-
379
- function probeFailure(code, status = 'failed') {
380
- return { status, code, classification: PROBE_CLASSIFICATIONS[code] || 'unknown', ...PROBE_FAILURES[code] };
381
- }
382
-
383
- function classifyProbeError(error, method) {
384
- if (error?.status === 401 || error?.status === 403) {
385
- return error.status === 403 ? 'permission_denied' : 'unauthorized';
386
- }
387
- if (error?.code === 'network_error' || ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'].includes(error?.code)) {
388
- return 'network';
389
- }
390
- const text = String(error?.message || '').toLowerCase();
391
- if (error?.code === 'auth_error' && text.includes('refresh')) return 'refresh_failed';
392
- if (method === 'gck' && error?.code === 'auth_error') return 'invalid_browser_session';
393
- return 'unknown';
394
- }
395
-
396
- /**
397
- * Classify credentials without returning or logging any credential material.
398
- * This is deliberately pure so diagnostics cannot refresh, persist, or probe.
399
- */
400
- export function classifyCredentialState(credentials, method, now = Date.now()) {
401
- if (!credentials) return 'missing';
402
- if (!AUTH_METHODS.has(method)) return 'malformed';
403
-
404
- if (method === 'basic') {
405
- const hasUser = typeof credentials.username === 'string' && credentials.username.length > 0;
406
- const hasPassword = typeof credentials.password === 'string' && credentials.password.length > 0;
407
- return hasUser && hasPassword ? 'available' : 'malformed';
408
- }
409
-
410
- if (method === 'gck') {
411
- const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
412
- const hasCookies = typeof credentials.cookies === 'string' && credentials.cookies.length > 0;
413
- return hasToken && hasCookies ? 'available' : 'malformed';
414
- }
415
-
416
- if (credentials.access_token !== undefined && typeof credentials.access_token !== 'string') return 'malformed';
417
- if (credentials.refresh_token !== undefined && typeof credentials.refresh_token !== 'string') return 'malformed';
418
-
419
- const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
420
- if (!hasToken) return credentials.refresh_token ? 'refreshable' : 'missing';
421
- if (credentials.expires_at !== undefined && credentials.expires_at !== null && credentials.expires_at !== 0) {
422
- const expiresAt = Number(credentials.expires_at);
423
- if (!Number.isFinite(expiresAt)) return 'malformed';
424
- if (now >= (expiresAt - 300) * 1000) return credentials.refresh_token ? 'refreshable' : 'expired';
425
- }
426
- return 'available';
427
- }
428
-
429
61
  export class AuthManager {
430
62
  /**
431
63
  * @param {object} identity — the resolved-identity provider. Production
@@ -1002,4 +634,5 @@ export class AuthManager {
1002
634
  }
1003
635
  }
1004
636
 
1005
- export { saveCredentials, loadCredentials, deleteCredentials, askHidden };
637
+ export { saveCredentials, loadCredentials, deleteCredentials } from './auth/credential-store.js';
638
+ export { askHidden };
@@ -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
@@ -303,7 +303,7 @@ export function buildCLI() {
303
303
 
304
304
  if (shouldCheck) {
305
305
  const version = getVersion();
306
- checkLatest().then(latest => {
306
+ checkLatest({ background: true }).then(latest => {
307
307
  if (latest && version !== latest) {
308
308
  process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
309
309
  }
@@ -584,3 +584,5 @@ export function buildCLI() {
584
584
 
585
585
  // Run if invoked directly (bin/jsn.js calls cli.parse())
586
586
  export const cli = buildCLI();
587
+
588
+ export { cancelBackgroundChecks } from './commands/version.js';
@@ -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, {
@@ -132,8 +132,13 @@ export function openDocsDb(opts = {}) {
132
132
  const dbPath = opts.dbPath || getDocsDbPath();
133
133
  ensureDocsCacheDir();
134
134
  const db = new Database(dbPath);
135
- db.exec('PRAGMA journal_mode = WAL');
136
- return db;
135
+ try {
136
+ db.exec('PRAGMA journal_mode = WAL');
137
+ return db;
138
+ } catch (error) {
139
+ closeDocsDb(db);
140
+ throw error;
141
+ }
137
142
  }
138
143
 
139
144
  export function closeDocsDb(db) {
@@ -246,3 +251,18 @@ export function getDocsIndexStats(db) {
246
251
  export function setMeta(db, key, value) {
247
252
  db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)').run(key, value);
248
253
  }
254
+
255
+ /** Own a synchronous docs connection and roll back unfinished work on failure. */
256
+ export function withDocsDb(opts, operation) {
257
+ const db = openDocsDb(opts);
258
+ try {
259
+ return operation(db);
260
+ } catch (error) {
261
+ if (db.inTransaction) {
262
+ try { db.exec('ROLLBACK'); } catch { /* Preserve the original error. */ }
263
+ }
264
+ throw error;
265
+ } finally {
266
+ closeDocsDb(db);
267
+ }
268
+ }