@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.
@@ -1,7 +1,7 @@
1
1
  // CLI search against the docs SQLite database.
2
2
 
3
3
  import fs from 'node:fs';
4
- import { getDocsDbPath, hasEmbeddings, openDocsDb, closeDocsDb, getMeta } from './db.js';
4
+ import { getDocsDbPath, hasEmbeddings, openDocsDb, closeDocsDb, withDocsDb, getMeta } from './db.js';
5
5
  import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
6
6
 
7
7
  function safeParse(s) {
@@ -50,119 +50,119 @@ export function searchDocs(opts = {}) {
50
50
  throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
51
51
  }
52
52
 
53
- const db = openDocsDb({ dbPath });
54
- const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
55
- const offset = parseInt(opts.offset, 10) || 0;
56
- const bundle = opts.bundle ? String(opts.bundle) : null;
57
- const docType = opts.docType ? String(opts.docType) : null;
58
- const mode = opts.mode === 'keyword' ? 'keyword' : 'hybrid';
59
- const useHybrid = mode === 'hybrid' && hasEmbeddings(db);
60
-
61
- const filters = [];
62
- const params = { q, limit, offset };
63
- if (bundle) {
64
- filters.push('d.bundle = @bundle');
65
- params.bundle = bundle;
66
- }
67
- if (docType) {
68
- filters.push('d.doc_type = @docType');
69
- params.docType = docType;
70
- }
71
- const where = filters.length ? ' AND ' + filters.join(' AND ') : '';
72
-
73
- // Total matching docs (for pagination)
74
- const total = db
75
- .prepare(`SELECT COUNT(*) AS n FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid WHERE docs_fts MATCH @q${where}`)
76
- .get({ q, ...(bundle && { bundle }), ...(docType && { docType }) }).n;
77
-
78
- let rows;
79
- if (!useHybrid) {
80
- rows = db
81
- .prepare(
82
- `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release,
83
- json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
84
- snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
85
- bm25(docs_fts) AS score
86
- FROM docs_fts
87
- JOIN docs d ON d.id = docs_fts.rowid
88
- WHERE docs_fts MATCH @q${where}
89
- ORDER BY score
90
- LIMIT @limit OFFSET @offset`
91
- )
92
- .all(params);
93
- } else {
94
- const candParams = { q, cand: (limit + offset) * 4 + 20 };
95
- if (bundle) candParams.bundle = bundle;
96
- if (docType) candParams.docType = docType;
97
- const candidates = db
98
- .prepare(
99
- `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release, d.hrr_vector,
100
- json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
101
- snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
102
- bm25(docs_fts) AS bm25
103
- FROM docs_fts
104
- JOIN docs d ON d.id = docs_fts.rowid
105
- WHERE docs_fts MATCH @q${where}
106
- ORDER BY bm25
107
- LIMIT @cand`
108
- )
109
- .all(candParams);
110
-
111
- if (candidates.length === 0) {
112
- rows = [];
53
+ return withDocsDb({ dbPath }, (db) => {
54
+ const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
55
+ const offset = parseInt(opts.offset, 10) || 0;
56
+ const bundle = opts.bundle ? String(opts.bundle) : null;
57
+ const docType = opts.docType ? String(opts.docType) : null;
58
+ const mode = opts.mode === 'keyword' ? 'keyword' : 'hybrid';
59
+ const useHybrid = mode === 'hybrid' && hasEmbeddings(db);
60
+
61
+ const filters = [];
62
+ const params = { q, limit, offset };
63
+ if (bundle) {
64
+ filters.push('d.bundle = @bundle');
65
+ params.bundle = bundle;
66
+ }
67
+ if (docType) {
68
+ filters.push('d.doc_type = @docType');
69
+ params.docType = docType;
70
+ }
71
+ const where = filters.length ? ' AND ' + filters.join(' AND ') : '';
72
+
73
+ // Total matching docs (for pagination)
74
+ const total = db
75
+ .prepare(`SELECT COUNT(*) AS n FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid WHERE docs_fts MATCH @q${where}`)
76
+ .get({ q, ...(bundle && { bundle }), ...(docType && { docType }) }).n;
77
+
78
+ let rows;
79
+ if (!useHybrid) {
80
+ rows = db
81
+ .prepare(
82
+ `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release,
83
+ json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
84
+ snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
85
+ bm25(docs_fts) AS score
86
+ FROM docs_fts
87
+ JOIN docs d ON d.id = docs_fts.rowid
88
+ WHERE docs_fts MATCH @q${where}
89
+ ORDER BY score
90
+ LIMIT @limit OFFSET @offset`
91
+ )
92
+ .all(params);
113
93
  } else {
114
- const hrrDim = (() => {
115
- const value = getMeta(db, 'hrr_dim');
116
- const n = parseInt(value, 10);
117
- return Number.isInteger(n) ? n : DEFAULT_DIM;
118
- })();
119
- const queryVec = encodeText(q, hrrDim);
120
- const queryTokens = new Set(q.toLowerCase().split(/\s+/).filter(Boolean));
121
-
122
- const bmVals = candidates.map((c) => c.bm25);
123
- const bmMin = Math.min(...bmVals);
124
- const bmMax = Math.max(...bmVals);
125
- const bmRange = bmMax - bmMin || 1;
126
-
127
- const FTS_W = 0.4, JACCARD_W = 0.3, HRR_W = 0.3;
128
-
129
- for (const c of candidates) {
130
- const ftsScore = 1 - (c.bm25 - bmMin) / bmRange;
131
- const contentTokens = new Set(
132
- `${c.title || ''} ${c.path || ''}`.toLowerCase().split(/\s+/).filter(Boolean)
133
- );
134
- let inter = 0;
135
- for (const t of queryTokens) if (contentTokens.has(t)) inter++;
136
- const union = queryTokens.size + contentTokens.size - inter;
137
- const jaccard = union ? inter / union : 0;
138
-
139
- let hrrSim = 0.5;
140
- if (c.hrr_vector) {
141
- hrrSim = (similarity(queryVec, bytesToPhases(c.hrr_vector)) + 1) / 2;
94
+ const candParams = { q, cand: (limit + offset) * 4 + 20 };
95
+ if (bundle) candParams.bundle = bundle;
96
+ if (docType) candParams.docType = docType;
97
+ const candidates = db
98
+ .prepare(
99
+ `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release, d.hrr_vector,
100
+ json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
101
+ snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
102
+ bm25(docs_fts) AS bm25
103
+ FROM docs_fts
104
+ JOIN docs d ON d.id = docs_fts.rowid
105
+ WHERE docs_fts MATCH @q${where}
106
+ ORDER BY bm25
107
+ LIMIT @cand`
108
+ )
109
+ .all(candParams);
110
+
111
+ if (candidates.length === 0) {
112
+ rows = [];
113
+ } else {
114
+ const hrrDim = (() => {
115
+ const value = getMeta(db, 'hrr_dim');
116
+ const n = parseInt(value, 10);
117
+ return Number.isInteger(n) ? n : DEFAULT_DIM;
118
+ })();
119
+ const queryVec = encodeText(q, hrrDim);
120
+ const queryTokens = new Set(q.toLowerCase().split(/\s+/).filter(Boolean));
121
+
122
+ const bmVals = candidates.map((c) => c.bm25);
123
+ const bmMin = Math.min(...bmVals);
124
+ const bmMax = Math.max(...bmVals);
125
+ const bmRange = bmMax - bmMin || 1;
126
+
127
+ const FTS_W = 0.4, JACCARD_W = 0.3, HRR_W = 0.3;
128
+
129
+ for (const c of candidates) {
130
+ const ftsScore = 1 - (c.bm25 - bmMin) / bmRange;
131
+ const contentTokens = new Set(
132
+ `${c.title || ''} ${c.path || ''}`.toLowerCase().split(/\s+/).filter(Boolean)
133
+ );
134
+ let inter = 0;
135
+ for (const t of queryTokens) if (contentTokens.has(t)) inter++;
136
+ const union = queryTokens.size + contentTokens.size - inter;
137
+ const jaccard = union ? inter / union : 0;
138
+
139
+ let hrrSim = 0.5;
140
+ if (c.hrr_vector) {
141
+ hrrSim = (similarity(queryVec, bytesToPhases(c.hrr_vector)) + 1) / 2;
142
+ }
143
+
144
+ c.score = FTS_W * ftsScore + JACCARD_W * jaccard + HRR_W * hrrSim;
145
+ delete c.hrr_vector;
146
+ delete c.bm25;
142
147
  }
143
148
 
144
- c.score = FTS_W * ftsScore + JACCARD_W * jaccard + HRR_W * hrrSim;
145
- delete c.hrr_vector;
146
- delete c.bm25;
149
+ candidates.sort((a, b) => b.score - a.score);
150
+ rows = candidates.slice(offset, offset + limit);
147
151
  }
148
-
149
- candidates.sort((a, b) => b.score - a.score);
150
- rows = candidates.slice(offset, offset + limit);
151
152
  }
152
- }
153
153
 
154
- closeDocsDb(db);
155
154
 
156
- // Post-process canonical_url JSON strings that may contain escaped content.
157
- for (const row of rows) {
158
- if (row.canonical_url) row.canonical_url = safeParse(row.canonical_url) || row.canonical_url;
159
- }
155
+ // Post-process canonical_url JSON strings that may contain escaped content.
156
+ for (const row of rows) {
157
+ if (row.canonical_url) row.canonical_url = safeParse(row.canonical_url) || row.canonical_url;
158
+ }
160
159
 
161
- return {
162
- query: q,
163
- mode: useHybrid ? 'hybrid' : 'keyword',
164
- total,
165
- count: rows.length,
166
- results: rows,
167
- };
160
+ return {
161
+ query: q,
162
+ mode: useHybrid ? 'hybrid' : 'keyword',
163
+ total,
164
+ count: rows.length,
165
+ results: rows,
166
+ };
167
+ });
168
168
  }
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import http from 'node:http';
6
6
  import { URL } from 'node:url';
7
7
  import Database from 'better-sqlite3';
8
- import { getDocsDbPath, hasEmbeddings } from './db.js';
8
+ import { getDocsDbPath, hasEmbeddings, closeDocsDb } from './db.js';
9
9
  import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
10
10
 
11
11
  const PUBLIC_DIR = path.join(import.meta.dirname, 'public');
@@ -236,6 +236,8 @@ export function serveDocs(opts = {}) {
236
236
  }
237
237
  });
238
238
 
239
+ server.once('close', () => closeDocsDb(db));
240
+
239
241
  return new Promise((resolve, reject) => {
240
242
  let currentPort = parseInt(port, 10);
241
243
  const maxAttempts = 10;
@@ -263,6 +265,7 @@ export function serveDocs(opts = {}) {
263
265
  err.message = `Could not find an open port between ${port} and ${currentPort}. Try specifying one with --port.`;
264
266
  }
265
267
  resolved = true;
268
+ closeDocsDb(db);
266
269
  reject(err);
267
270
  }
268
271
 
@@ -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,3 @@
1
- import { createPrompt, useKeypress, usePagination, useState, isDownKey, isEnterKey, isUpKey } from '@inquirer/core';
2
1
  import { loginWizard, loginProfile, showAuthStatus, switchProfile, modifyProfile, logoutProfile, removeConnection } from './auth.js';
3
2
  import { errUsage } from '../errors.js';
4
3
  import { declareCapabilities } from '../capabilities.js';
@@ -18,32 +17,35 @@ const HUB_CHOICES = [
18
17
  ];
19
18
 
20
19
  /** The setup hub prompt; q and Escape resolve with no selected action. */
21
- export const authHubPrompt = createPrompt((config, done) => {
22
- const [active, setActive] = useState(0);
23
- const choices = config.choices;
20
+ export const authHubPrompt = async (...args) => {
21
+ const { createPrompt, useKeypress, usePagination, useState, isDownKey, isEnterKey, isUpKey } = await import('@inquirer/core');
22
+ return createPrompt((config, done) => {
23
+ const [active, setActive] = useState(0);
24
+ const choices = config.choices;
24
25
 
25
- useKeypress((key) => {
26
- if (key.name === 'q' || key.name === 'escape') {
27
- done(undefined);
28
- return;
29
- }
30
- if (isEnterKey(key)) {
31
- done(choices[active].value);
32
- return;
33
- }
34
- if (isUpKey(key) && active > 0) setActive(active - 1);
35
- if (isDownKey(key) && active < choices.length - 1) setActive(active + 1);
36
- });
26
+ useKeypress((key) => {
27
+ if (key.name === 'q' || key.name === 'escape') {
28
+ done(undefined);
29
+ return;
30
+ }
31
+ if (isEnterKey(key)) {
32
+ done(choices[active].value);
33
+ return;
34
+ }
35
+ if (isUpKey(key) && active > 0) setActive(active - 1);
36
+ if (isDownKey(key) && active < choices.length - 1) setActive(active + 1);
37
+ });
37
38
 
38
- const page = usePagination({
39
- items: choices,
40
- active,
41
- pageSize: choices.length,
42
- loop: false,
43
- renderItem: ({ item, isActive }) => `${isActive ? '❯' : ' '} ${item.name}`,
44
- });
45
- return `${config.message}\n${page}\n\n↑↓ navigate • ⏎ select • q/Esc exit`;
46
- });
39
+ const page = usePagination({
40
+ items: choices,
41
+ active,
42
+ pageSize: choices.length,
43
+ loop: false,
44
+ renderItem: ({ item, isActive }) => `${isActive ? '❯' : ' '} ${item.name}`,
45
+ });
46
+ return `${config.message}\n${page}\n\n↑↓ navigate • ⏎ select • q/Esc exit`;
47
+ })(...args);
48
+ };
47
49
 
48
50
  /** Interactive hub: what do you want to do? Returns an action or undefined on exit. */
49
51
  export async function authHubMenu({ promptFn = authHubPrompt, input, output } = {}) {
@@ -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, {
@@ -19,14 +19,28 @@ export function getVersion() {
19
19
  }
20
20
  }
21
21
 
22
- export async function checkLatest() {
22
+ const backgroundChecks = new Set();
23
+
24
+ export function cancelBackgroundChecks() {
25
+ for (const controller of backgroundChecks) controller.abort();
26
+ backgroundChecks.clear();
27
+ }
28
+
29
+ export async function checkLatest({ timeout = 1500, background = false } = {}) {
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), timeout);
32
+ timer.unref();
33
+ if (background) backgroundChecks.add(controller);
23
34
  try {
24
- const res = await fetch(NPM_REGISTRY);
35
+ const res = await fetch(NPM_REGISTRY, { signal: controller.signal });
25
36
  if (!res.ok) return null;
26
37
  const data = await res.json();
27
38
  return data.version;
28
39
  } catch {
29
40
  return null;
41
+ } finally {
42
+ clearTimeout(timer);
43
+ backgroundChecks.delete(controller);
30
44
  }
31
45
  }
32
46
 
@@ -1,4 +1,5 @@
1
- import yaml from 'js-yaml';
1
+ // js-yaml 5 ships ESM-only named exports; there is no default export.
2
+ import { load as loadYaml, dump as dumpYaml } from 'js-yaml';
2
3
 
3
4
  const DEFAULT_DELIMITERS = ['---', '---'];
4
5
 
@@ -27,7 +28,8 @@ export function parseFrontmatter(input, options = {}) {
27
28
 
28
29
  const closeIndex = frontmatter.indexOf(`\n${close}`);
29
30
  const raw = closeIndex === -1 ? frontmatter : frontmatter.slice(0, closeIndex);
30
- const parsed = yaml.load(raw);
31
+ // js-yaml 5 throws on an empty document; an empty block means no metadata.
32
+ const parsed = raw.trim() === '' ? {} : loadYaml(raw);
31
33
  const data = parsed === undefined ? {} : parsed;
32
34
 
33
35
  if (closeIndex === -1) return { data, content: '' };
@@ -41,7 +43,7 @@ export function parseFrontmatter(input, options = {}) {
41
43
  /** Serialize docs metadata and body using gray-matter-compatible YAML framing. */
42
44
  export function stringifyFrontmatter(body, data, options = {}) {
43
45
  const [open, close] = delimiters(options);
44
- const yamlText = yaml.dump(data, options).trim();
46
+ const yamlText = dumpYaml(data, options).trim();
45
47
  const prefix = yamlText === '{}' ? '' : `${open}\n${yamlText}\n${close}\n`;
46
48
  const content = String(body);
47
49
  return prefix + (content.endsWith('\n') ? content : `${content}\n`);