@jacebenson/jsn 3.0.6 → 3.1.0
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 +60 -285
- package/package.json +3 -1
- package/scripts/postinstall.mjs +80 -0
- package/scripts/pre-commit-check.sh +61 -0
- package/scripts/release.js +62 -0
- package/scripts/tag-reminder.js +30 -0
- package/skills/servicenow/SKILL.md +41 -4
- package/src/app.js +5 -5
- package/src/cli.js +60 -0
- package/src/commands/_ticket.js +1 -1
- package/src/commands/dev/_generic.js +13 -13
- package/src/commands/dev/eval.js +1 -1
- package/src/commands/dev/flows.js +31 -8
- package/src/commands/dev/forms.js +16 -6
- package/src/commands/dev/lists.js +16 -6
- package/src/commands/dev/logs.js +11 -1
- package/src/commands/dev/scopes.js +4 -3
- package/src/commands/dev/scrapi.js +13 -13
- package/src/commands/dev/updatesets.js +6 -5
- package/src/commands/records.js +3 -3
- package/src/help.js +52 -9
- package/src/helpers.js +36 -33
- package/src/paginated-search.js +178 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Custom scraPI command: list/show individual operations + one-shot API definition creation
|
|
2
2
|
//
|
|
3
3
|
// The 'create' subcommand lets you build a full Scripted REST API in one call:
|
|
4
|
-
// jsn
|
|
4
|
+
// jsn scrapi create \
|
|
5
5
|
// --name "SL Managed Services" --service-id managed_services --namespace slapi \
|
|
6
6
|
// --resource create=POST:/create:./create.js \
|
|
7
7
|
// --resource comment=POST:/comment:./comment.js
|
|
@@ -74,7 +74,7 @@ export function scrapiCmd(wrap) {
|
|
|
74
74
|
builder: (y) => y
|
|
75
75
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident")' })
|
|
76
76
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
|
|
77
|
-
.option('limit', { alias: 'l', type: 'number', default:
|
|
77
|
+
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
78
78
|
handler: wrap(async (argv, app) => {
|
|
79
79
|
const columns = argv.columns ? argv.columns.split(',') : ['name', 'sys_ws_definition', 'http_method', 'relative_path', 'sys_scope'];
|
|
80
80
|
const query = argv.query || '';
|
|
@@ -97,8 +97,8 @@ export function scrapiCmd(wrap) {
|
|
|
97
97
|
}, {
|
|
98
98
|
summary: `${records.length} operation(s)`,
|
|
99
99
|
breadcrumbs: [
|
|
100
|
-
{ action: 'create', cmd: 'jsn
|
|
101
|
-
{ action: 'show', cmd: 'jsn
|
|
100
|
+
{ action: 'create', cmd: 'jsn scrapi create --name "My API" --service-id my-api --namespace myapp --resource get=GET:/items:./script.js', description: 'Create a new REST API' },
|
|
101
|
+
{ action: 'show', cmd: 'jsn scrapi show <name>', description: 'Show operation details' },
|
|
102
102
|
],
|
|
103
103
|
});
|
|
104
104
|
}),
|
|
@@ -128,7 +128,7 @@ export function scrapiCmd(wrap) {
|
|
|
128
128
|
return app.ok(records[0], {
|
|
129
129
|
summary: `Operation: ${getStringField(records[0], 'name') || id}`,
|
|
130
130
|
breadcrumbs: [
|
|
131
|
-
{ action: 'list', cmd: 'jsn
|
|
131
|
+
{ action: 'list', cmd: 'jsn scrapi list', description: 'Back to all operations' },
|
|
132
132
|
],
|
|
133
133
|
});
|
|
134
134
|
}
|
|
@@ -182,7 +182,7 @@ export function scrapiCmd(wrap) {
|
|
|
182
182
|
formatted += ` ${(method + ' ').slice(0, 7)} ${relPath.padEnd(30)} ${opName}${active !== 'true' ? ' (inactive)' : ''}\n`;
|
|
183
183
|
}
|
|
184
184
|
} else {
|
|
185
|
-
formatted += `\n (no resources — run jsn
|
|
185
|
+
formatted += `\n (no resources — run jsn scrapi create --resource ... to add some)\n`;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
app.ok({
|
|
@@ -207,8 +207,8 @@ export function scrapiCmd(wrap) {
|
|
|
207
207
|
}, {
|
|
208
208
|
summary: `API: ${defName} — ${childOps.length} resource(s)`,
|
|
209
209
|
breadcrumbs: [
|
|
210
|
-
{ action: 'create', cmd: 'jsn
|
|
211
|
-
{ action: 'list', cmd: 'jsn
|
|
210
|
+
{ action: 'create', cmd: 'jsn scrapi create --name "..." --service-id ... --namespace ... --resource ...', description: 'Create a new API' },
|
|
211
|
+
{ action: 'list', cmd: 'jsn scrapi list', description: 'Back to all operations' },
|
|
212
212
|
],
|
|
213
213
|
});
|
|
214
214
|
}),
|
|
@@ -316,8 +316,8 @@ export function scrapiCmd(wrap) {
|
|
|
316
316
|
summary: `Created API "${apiName}" with ${operations.length} operation(s)`,
|
|
317
317
|
notice: `View API: ${apiURL}`,
|
|
318
318
|
breadcrumbs: [
|
|
319
|
-
{ action: 'show', cmd: `jsn
|
|
320
|
-
{ action: 'add', cmd: `jsn
|
|
319
|
+
{ action: 'show', cmd: `jsn scrapi list --query "web_service_definition=${defSysId}"`, description: 'List operations for this API' },
|
|
320
|
+
{ action: 'add', cmd: `jsn scrapi add-resource --api "${serviceId}" --name <name>=METHOD:/path:./script.js`, description: 'Add another resource' },
|
|
321
321
|
],
|
|
322
322
|
});
|
|
323
323
|
}),
|
|
@@ -333,9 +333,9 @@ export function scrapiCmd(wrap) {
|
|
|
333
333
|
console.log(' create Create a new API with operations (one-shot)');
|
|
334
334
|
console.log('');
|
|
335
335
|
console.log('Examples:');
|
|
336
|
-
console.log(' jsn
|
|
337
|
-
console.log(' jsn
|
|
338
|
-
console.log(' jsn
|
|
336
|
+
console.log(' jsn scrapi list');
|
|
337
|
+
console.log(' jsn scrapi show my_operation');
|
|
338
|
+
console.log(' jsn scrapi create \\');
|
|
339
339
|
console.log(' --name "My API" --service-id my-api --namespace myapp \\');
|
|
340
340
|
console.log(' --resource "create=POST:/create:./create.js" \\');
|
|
341
341
|
console.log(' --resource "list=GET:/list:./list.js"');
|
|
@@ -15,7 +15,7 @@ export function updateSetsCmd(wrap) {
|
|
|
15
15
|
builder: (y) => y
|
|
16
16
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
|
|
17
17
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
|
|
18
|
-
.option('limit', { alias: 'l', type: 'number', default:
|
|
18
|
+
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
19
19
|
handler: wrap(async (argv, app) => {
|
|
20
20
|
const columns = argv.columns ? argv.columns.split(',') : ['name', 'state', 'application'];
|
|
21
21
|
const query = argv.query || '';
|
|
@@ -24,6 +24,7 @@ export function updateSetsCmd(wrap) {
|
|
|
24
24
|
app, table: 'sys_update_set', singular: 'update set', columns, limit: argv.limit, query, labelField: 'name',
|
|
25
25
|
formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'state') || '?'}]`,
|
|
26
26
|
});
|
|
27
|
+
if (picked === undefined) return; // user cancelled
|
|
27
28
|
if (picked) {
|
|
28
29
|
picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_update_set' };
|
|
29
30
|
return app.ok(picked, { summary: `Update set: ${getStringField(picked, 'name')}` });
|
|
@@ -151,12 +152,12 @@ export function updateSetsCmd(wrap) {
|
|
|
151
152
|
breadcrumbs: [
|
|
152
153
|
{
|
|
153
154
|
action: 'set',
|
|
154
|
-
cmd: `jsn
|
|
155
|
+
cmd: `jsn updatesets set "${argv.name}"`,
|
|
155
156
|
description: 'Switch to this update set',
|
|
156
157
|
},
|
|
157
158
|
{
|
|
158
159
|
action: 'complete',
|
|
159
|
-
cmd: `jsn
|
|
160
|
+
cmd: `jsn updatesets complete "${argv.name}"`,
|
|
160
161
|
description: 'Mark as complete when done',
|
|
161
162
|
},
|
|
162
163
|
],
|
|
@@ -225,9 +226,9 @@ export function updateSetsCmd(wrap) {
|
|
|
225
226
|
console.log(' export <name> Export an update set to XML');
|
|
226
227
|
console.log(' complete <name> Mark an update set as complete (coming soon)');
|
|
227
228
|
console.log(' yolo Silence the "Default update set" warning');
|
|
228
|
-
console.log('\nRun "jsn
|
|
229
|
+
console.log('\nRun "jsn updatesets <command> --help" for details.');
|
|
229
230
|
console.log('\nTip: Create an update set first:');
|
|
230
|
-
console.log(' jsn
|
|
231
|
+
console.log(' jsn updatesets create --name "My Feature"');
|
|
231
232
|
console.log(' # → Auto-set as current, then record changes are captured.');
|
|
232
233
|
}
|
|
233
234
|
},
|
package/src/commands/records.js
CHANGED
|
@@ -74,7 +74,7 @@ export function recordsCmd(wrap) {
|
|
|
74
74
|
const breadcrumbs = [
|
|
75
75
|
{ action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
|
|
76
76
|
{ action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
|
|
77
|
-
{ action: 'columns', cmd: `jsn
|
|
77
|
+
{ action: 'columns', cmd: `jsn columns --table ${table}`, description: 'View available columns' },
|
|
78
78
|
];
|
|
79
79
|
if (argv['sys-id']) {
|
|
80
80
|
breadcrumbs.unshift({
|
|
@@ -188,12 +188,12 @@ export function recordsCmd(wrap) {
|
|
|
188
188
|
const breadcrumbs = [
|
|
189
189
|
...result.flows.map(f => ({
|
|
190
190
|
action: 'show',
|
|
191
|
-
cmd: `jsn
|
|
191
|
+
cmd: `jsn flows show "${f.flow}"`,
|
|
192
192
|
description: `View flow details for ${f.flow}`,
|
|
193
193
|
})),
|
|
194
194
|
...result.businessRules.slice(0, 5).map(br => ({
|
|
195
195
|
action: 'show',
|
|
196
|
-
cmd: `jsn
|
|
196
|
+
cmd: `jsn rules show "${br.name}"`,
|
|
197
197
|
description: `View business rule: ${br.name}`,
|
|
198
198
|
})),
|
|
199
199
|
];
|
package/src/help.js
CHANGED
|
@@ -9,23 +9,63 @@ const COMMAND_GROUPS = {
|
|
|
9
9
|
{ name: 'tasks', alias: 'task', desc: 'Manage catalog tasks' },
|
|
10
10
|
{ name: 'tickets', alias: 'ticket', desc: 'Query generic tickets' },
|
|
11
11
|
],
|
|
12
|
-
'
|
|
12
|
+
'AUTOMATION': [
|
|
13
|
+
{ name: 'flows', desc: 'Manage Flow Designer flows' },
|
|
14
|
+
{ name: 'actions', alias: 'action', desc: 'Manage flow actions (sys_hub_action_type_definition)' },
|
|
15
|
+
{ name: 'rules', alias: 'br', desc: 'Manage business rules' },
|
|
16
|
+
{ name: 'scrapi', desc: 'Manage Scripted REST APIs' },
|
|
17
|
+
{ name: 'updatesets', desc: 'Manage update sets (set, yolo, export, import)' },
|
|
18
|
+
{ name: 'eval', desc: 'Run server-side JavaScript' },
|
|
19
|
+
{ name: 'rest', desc: 'Make raw REST API calls' },
|
|
20
|
+
],
|
|
21
|
+
'ACCESS': [
|
|
22
|
+
{ name: 'acls', alias: 'acl', desc: 'Manage access controls' },
|
|
23
|
+
{ name: 'roles', alias: 'role', desc: 'Manage roles' },
|
|
24
|
+
{ name: 'scopes', desc: 'Manage application scopes' },
|
|
25
|
+
{ name: 'properties', alias: 'prop', desc: 'Manage system properties' },
|
|
26
|
+
{ name: 'privileges', alias: 'priv', desc: 'Manage scope privileges' },
|
|
27
|
+
{ name: 'securitytypes', alias: 'st', desc: 'Manage security types' },
|
|
28
|
+
{ name: 'aliases', alias: 'als', desc: 'Manage table aliases' },
|
|
29
|
+
],
|
|
30
|
+
'USER EXPERIENCE': [
|
|
31
|
+
{ name: 'forms', desc: 'Manage form layouts' },
|
|
32
|
+
{ name: 'lists', desc: 'Manage list layouts' },
|
|
33
|
+
{ name: 'clientscripts', alias: 'cs', desc: 'Manage client scripts' },
|
|
34
|
+
{ name: 'uipolicies', alias: 'up', desc: 'Manage UI policies' },
|
|
35
|
+
{ name: 'uiactions', alias: 'ua', desc: 'Manage UI actions' },
|
|
36
|
+
{ name: 'sppages', desc: 'Manage Service Portal pages' },
|
|
37
|
+
{ name: 'spwidgets', desc: 'Manage Service Portal widgets' },
|
|
38
|
+
{ name: 'uipages', desc: 'Manage UI pages' },
|
|
39
|
+
{ name: 'appmenu', desc: 'Manage application menus' },
|
|
40
|
+
{ name: 'listcontrols', alias: 'lc', desc: 'Manage list controls' },
|
|
41
|
+
{ name: 'views', alias: 'vw', desc: 'Manage views' },
|
|
42
|
+
{ name: 'uxscripts', alias: 'ux', desc: 'Manage UX source scripts' },
|
|
43
|
+
{ name: 'catalogscripts', desc: 'Manage catalog client scripts' },
|
|
44
|
+
{ name: 'cataloguipolicies', alias: 'cup', desc: 'Manage catalog UI policies' },
|
|
45
|
+
],
|
|
46
|
+
'DATA': [
|
|
13
47
|
{ name: 'records', desc: 'Generic Table API for any table' },
|
|
48
|
+
{ name: 'tables', alias: 't', desc: 'Manage table definitions (sys_db_object)' },
|
|
49
|
+
{ name: 'columns', alias: 'col', desc: 'Manage column definitions (sys_dictionary)' },
|
|
50
|
+
{ name: 'includes', alias: 'si', desc: 'Manage script includes' },
|
|
51
|
+
{ name: 'import', desc: 'Manage import sets' },
|
|
52
|
+
{ name: 'logs', desc: 'View system logs' },
|
|
14
53
|
{ name: 'users', alias: 'user', desc: 'Manage ServiceNow users' },
|
|
15
54
|
{ name: 'groups', alias: 'group', desc: 'Manage groups' },
|
|
16
55
|
{ name: 'groupmembers', alias: 'gm', desc: 'Manage group memberships' },
|
|
17
56
|
{ name: 'grouproles', alias: 'gr', desc: 'Manage group roles' },
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{ name: 'dev', desc: 'Manage development artifacts (flows, includes, rules, ACLs, etc.)' },
|
|
21
|
-
],
|
|
22
|
-
'CONFIGURATION': [
|
|
23
|
-
{ name: 'setup', desc: 'Interactive first-time setup' },
|
|
24
|
-
{ name: 'auth', desc: 'Manage OAuth authentication' },
|
|
25
|
-
{ name: 'profiles', alias: 'profile', desc: 'Manage instance profiles' },
|
|
57
|
+
{ name: 'relationships', alias: 'rel', desc: 'Manage table relationships' },
|
|
58
|
+
{ name: 'appmodules', alias: 'am', desc: 'Manage application modules' },
|
|
26
59
|
],
|
|
27
60
|
};
|
|
28
61
|
|
|
62
|
+
const CONFIGURATION_COMMANDS = [
|
|
63
|
+
{ name: 'setup', desc: 'Interactive first-time setup' },
|
|
64
|
+
{ name: 'auth', desc: 'Manage OAuth authentication' },
|
|
65
|
+
{ name: 'profiles', alias: 'profile', desc: 'Manage instance profiles' },
|
|
66
|
+
{ name: 'dev', desc: '[DEPRECATED] All dev subcommands are now top-level — use jsn flows, jsn acls, etc.' },
|
|
67
|
+
];
|
|
68
|
+
|
|
29
69
|
const UTILITY_COMMANDS = [
|
|
30
70
|
{ name: 'skill', desc: 'Manage the jsn AI agent skill file (show, fetch, install)' },
|
|
31
71
|
{ name: 'version', desc: 'Show version information (--check for npm updates)' },
|
|
@@ -67,6 +107,7 @@ LEARN MORE
|
|
|
67
107
|
|
|
68
108
|
export function renderHelp() {
|
|
69
109
|
const sections = Object.entries(COMMAND_GROUPS).map(([name, cmds]) => renderGroup(name, cmds));
|
|
110
|
+
const configSection = renderGroup('CONFIGURATION', CONFIGURATION_COMMANDS);
|
|
70
111
|
const utilitySection = renderGroup('UTILITY', UTILITY_COMMANDS);
|
|
71
112
|
|
|
72
113
|
return [
|
|
@@ -76,6 +117,8 @@ export function renderHelp() {
|
|
|
76
117
|
'',
|
|
77
118
|
...sections,
|
|
78
119
|
'',
|
|
120
|
+
configSection,
|
|
121
|
+
'',
|
|
79
122
|
utilitySection,
|
|
80
123
|
'',
|
|
81
124
|
renderFlags(),
|
package/src/helpers.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Shared helper utilities
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import
|
|
4
|
+
import { paginatedSearch } from './paginated-search.js';
|
|
5
5
|
import { isTTY, FormatAuto } from './output.js';
|
|
6
6
|
|
|
7
7
|
export function getStringField(record, field) {
|
|
@@ -89,53 +89,56 @@ export function resolveFieldsParam(columns) {
|
|
|
89
89
|
* @param {string} opts.labelField — field used to match selection (default: 'name')
|
|
90
90
|
* @returns {Promise<void>|null} null if no selection made or non-interactive
|
|
91
91
|
*/
|
|
92
|
-
export async function interactiveList({ app, table, singular, columns, limit =
|
|
92
|
+
export async function interactiveList({ app, table, singular, columns, limit = 50, query = '', formatLabel, labelField = 'name' }) {
|
|
93
|
+
app.requireInstance();
|
|
93
94
|
const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
|
|
94
95
|
if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin) || query) {
|
|
95
96
|
return null; // not interactive — caller should fall back to text/table
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id' && c !== '*')];
|
|
99
|
-
const params = new URLSearchParams();
|
|
100
|
-
params.set('sysparm_limit', String(limit));
|
|
101
|
-
params.set('sysparm_display_value', 'all');
|
|
102
100
|
const pickerFields = pickerColumns.join(',');
|
|
103
|
-
if (pickerFields) params.set('sysparm_fields', pickerFields);
|
|
104
|
-
params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
|
|
105
|
-
|
|
106
|
-
const records = await app.sdk.list(table, params);
|
|
107
|
-
if (records.length === 0) return null;
|
|
108
|
-
|
|
109
|
-
const choices = records.map(r => ({
|
|
110
|
-
name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
|
|
111
|
-
value: r,
|
|
112
|
-
}));
|
|
113
101
|
|
|
102
|
+
// Get total count
|
|
103
|
+
let totalCount = 0;
|
|
114
104
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
105
|
+
totalCount = await app.sdk.aggregateCount(table, '');
|
|
106
|
+
} catch {
|
|
107
|
+
totalCount = 0;
|
|
108
|
+
}
|
|
109
|
+
if (totalCount === 0) return null;
|
|
110
|
+
|
|
111
|
+
// Server source adapter for paginatedSearch: (term, offset) => choices[]
|
|
112
|
+
async function serverSource(term, offset) {
|
|
113
|
+
const params = new URLSearchParams();
|
|
114
|
+
params.set('sysparm_limit', String(limit));
|
|
115
|
+
params.set('sysparm_display_value', 'all');
|
|
116
|
+
if (pickerFields) params.set('sysparm_fields', pickerFields);
|
|
117
|
+
if (term) {
|
|
118
|
+
params.set('sysparm_query', `${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
|
|
119
|
+
} else {
|
|
120
|
+
params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
|
|
121
|
+
params.set('sysparm_offset', String(offset));
|
|
127
122
|
}
|
|
128
|
-
|
|
123
|
+
const records = await app.sdk.list(table, params);
|
|
124
|
+
return records.map(r => ({
|
|
125
|
+
name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
|
|
126
|
+
value: r,
|
|
127
|
+
}));
|
|
129
128
|
}
|
|
130
|
-
}
|
|
131
129
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const selected = await paginatedSearch({
|
|
131
|
+
message: `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
|
|
132
|
+
pageSize: 10,
|
|
133
|
+
totalCount,
|
|
134
|
+
source: serverSource,
|
|
135
|
+
});
|
|
136
|
+
// null = cancelled → undefined so callers skip fallback table
|
|
137
|
+
if (selected === null) return undefined;
|
|
138
|
+
return selected?.value; // unwrap {name, value} → raw record
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
/**
|
|
138
|
-
* Known derived (read-only) ServiceNow fields that should not be set directly.
|
|
139
142
|
* Maps table name → array of field names that are computed by the platform.
|
|
140
143
|
* When a create/update payload contains these, a warning is emitted.
|
|
141
144
|
*/
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paginated interactive search prompt with server-side pagination.
|
|
3
|
+
*
|
|
4
|
+
* Uses raw readline for terminal control — @inquirer/search doesn't support
|
|
5
|
+
* scroll-based pagination (source only fires on keystrokes, not on scroll).
|
|
6
|
+
*
|
|
7
|
+
* Returns the selected choice ({name, value}), or null on cancel.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import readline from 'node:readline';
|
|
11
|
+
|
|
12
|
+
export async function paginatedSearch({ message, pageSize, totalCount, pageSize: pgSize, source }) {
|
|
13
|
+
const PAGE = pageSize || pgSize || 10;
|
|
14
|
+
let choices = [];
|
|
15
|
+
let active = 0;
|
|
16
|
+
let loaded = 0;
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
let term = '';
|
|
19
|
+
let outputLines = 0; // number of lines currently on screen
|
|
20
|
+
|
|
21
|
+
// Write + track: returns number of newlines written (cursor advance)
|
|
22
|
+
function writeLines(lines) {
|
|
23
|
+
const out = lines.join('\n');
|
|
24
|
+
process.stdout.write(out);
|
|
25
|
+
return lines.length - 1; // N elements = N-1 newlines = cursor advances this many
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function render() {
|
|
29
|
+
if (outputLines > 0) {
|
|
30
|
+
process.stdout.moveCursor(0, -outputLines);
|
|
31
|
+
process.stdout.clearScreenDown();
|
|
32
|
+
} else {
|
|
33
|
+
// First render: ensure we start on a fresh line
|
|
34
|
+
process.stdout.cursorTo(0);
|
|
35
|
+
process.stdout.clearLine(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const buf = [];
|
|
39
|
+
|
|
40
|
+
// Header: show message + count
|
|
41
|
+
const countInfo = totalCount > 0 ? ` (${loaded} of ${totalCount} loaded)` : '';
|
|
42
|
+
buf.push(`${message}${countInfo}`);
|
|
43
|
+
|
|
44
|
+
// Visible window: center active item
|
|
45
|
+
const start = Math.max(0, active - Math.floor(PAGE / 2));
|
|
46
|
+
const end = Math.min(choices.length, start + PAGE);
|
|
47
|
+
const visible = choices.slice(start, end);
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < visible.length; i++) {
|
|
50
|
+
const choice = visible[i];
|
|
51
|
+
const isActive = (start + i) === active;
|
|
52
|
+
const prefix = isActive ? '\x1b[7m' : '';
|
|
53
|
+
const suffix = isActive ? '\x1b[0m' : '';
|
|
54
|
+
const display = choice.name.length > 70 ? choice.name.slice(0, 67) + '...' : choice.name;
|
|
55
|
+
buf.push(` ${prefix}${display}${suffix}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pad to constant height
|
|
59
|
+
while (buf.length < PAGE + 2) {
|
|
60
|
+
buf.push('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Footer
|
|
64
|
+
const more = loaded < totalCount;
|
|
65
|
+
const footer = more
|
|
66
|
+
? `↑↓ navigate • ⏎ select • esc cancel • type to filter • ↓ at end loads more`
|
|
67
|
+
: `↑↓ navigate • ⏎ select • esc cancel • type to filter`;
|
|
68
|
+
buf.push('');
|
|
69
|
+
buf.push(footer);
|
|
70
|
+
|
|
71
|
+
// Search bar
|
|
72
|
+
buf.push(`> ${term}`);
|
|
73
|
+
|
|
74
|
+
outputLines = writeLines(buf);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cleanup() {
|
|
78
|
+
if (outputLines > 0) {
|
|
79
|
+
process.stdout.moveCursor(0, -outputLines);
|
|
80
|
+
process.stdout.clearScreenDown();
|
|
81
|
+
outputLines = 0;
|
|
82
|
+
}
|
|
83
|
+
// Ensure raw mode is off
|
|
84
|
+
try { process.stdin.setRawMode(false); } catch { /* ok */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const rl = readline.createInterface({
|
|
89
|
+
input: process.stdin,
|
|
90
|
+
output: process.stdout,
|
|
91
|
+
terminal: true,
|
|
92
|
+
});
|
|
93
|
+
try { process.stdin.setRawMode(true); } catch { /* non-TTY */ }
|
|
94
|
+
|
|
95
|
+
let inputBuf = '';
|
|
96
|
+
|
|
97
|
+
async function loadPage() {
|
|
98
|
+
if (loaded >= totalCount) return;
|
|
99
|
+
const newItems = await source('', loaded);
|
|
100
|
+
if (newItems.length > 0) {
|
|
101
|
+
choices = choices.concat(newItems);
|
|
102
|
+
loaded = Math.min(choices.length, totalCount);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Initial load
|
|
107
|
+
(async () => {
|
|
108
|
+
const initial = await source('', 0);
|
|
109
|
+
if (cancelled) return;
|
|
110
|
+
choices = initial;
|
|
111
|
+
loaded = Math.min(choices.length, totalCount || Infinity);
|
|
112
|
+
active = 0;
|
|
113
|
+
render();
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
rl.input.on('keypress', async (_str, key) => {
|
|
117
|
+
if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
118
|
+
cancelled = true;
|
|
119
|
+
rl.close();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
124
|
+
if (choices.length > 0 && active >= 0 && active < choices.length) {
|
|
125
|
+
rl.close();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (key.name === 'up' && active > 0) {
|
|
131
|
+
active--;
|
|
132
|
+
render();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (key.name === 'down') {
|
|
137
|
+
if (active < choices.length - 1) {
|
|
138
|
+
active++;
|
|
139
|
+
render();
|
|
140
|
+
} else if (loaded < totalCount) {
|
|
141
|
+
await loadPage();
|
|
142
|
+
if (active < choices.length - 1) active++;
|
|
143
|
+
render();
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (key.name === 'backspace') {
|
|
149
|
+
if (inputBuf.length > 0) inputBuf = inputBuf.slice(0, -1);
|
|
150
|
+
} else if (key.sequence && key.sequence.length === 1 && !key.meta && !key.ctrl) {
|
|
151
|
+
inputBuf += key.sequence;
|
|
152
|
+
} else {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Search
|
|
157
|
+
term = inputBuf;
|
|
158
|
+
if (term) {
|
|
159
|
+
const results = await source(term, 0);
|
|
160
|
+
choices = results;
|
|
161
|
+
loaded = results.length;
|
|
162
|
+
} else {
|
|
163
|
+
// Reset to browse mode — reload first page
|
|
164
|
+
const results = await source('', 0);
|
|
165
|
+
choices = results;
|
|
166
|
+
loaded = Math.min(results.length, totalCount || Infinity);
|
|
167
|
+
}
|
|
168
|
+
active = 0;
|
|
169
|
+
render();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
rl.on('close', () => {
|
|
173
|
+
cleanup();
|
|
174
|
+
const choice = (!cancelled && active >= 0 && active < choices.length) ? choices[active] : null;
|
|
175
|
+
resolve(cancelled ? null : choice);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|