@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/bin/jsn.js +7 -1
- package/package.json +3 -3
- package/src/app.js +10 -23
- package/src/auth/browser-session.js +21 -0
- package/src/auth/credential-state.js +124 -0
- package/src/auth/credential-store.js +177 -0
- package/src/auth/oauth.js +66 -0
- package/src/auth.js +11 -378
- package/src/cli-adapter.js +12 -4
- package/src/cli.js +3 -1
- package/src/commands/_generic.js +13 -9
- package/src/commands/_ticket.js +14 -11
- package/src/commands/docs/db.js +22 -2
- package/src/commands/docs/docs.js +13 -13
- package/src/commands/docs/embed.js +33 -33
- package/src/commands/docs/ingest.js +58 -58
- package/src/commands/docs/refresh.js +91 -91
- package/src/commands/docs/search.js +108 -108
- package/src/commands/docs/serve.js +4 -1
- package/src/commands/groups.js +11 -8
- package/src/commands/records.js +11 -16
- package/src/commands/requests.js +1 -1
- package/src/commands/setup.js +27 -25
- package/src/commands/tickets.js +14 -6
- package/src/commands/users.js +11 -8
- package/src/commands/version.js +16 -2
- package/src/frontmatter.js +5 -3
- package/src/helpers.js +45 -1
- package/src/paginated-search.js +162 -161
- package/src/prompts.js +15 -9
- package/src/sdk.js +79 -92
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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/setup.js
CHANGED
|
@@ -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 =
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 } = {}) {
|
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
|
@@ -19,14 +19,28 @@ export function getVersion() {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
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
|
|
package/src/frontmatter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 =
|
|
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`);
|