@jacebenson/jsn 3.0.5 → 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 +64 -1
- package/src/commands/_ticket.js +1 -1
- package/src/commands/dev/_generic.js +13 -13
- package/src/commands/dev/_simple.js +0 -1
- 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 +344 -0
- package/src/commands/dev/updatesets.js +6 -5
- package/src/commands/dev.js +3 -2
- package/src/commands/records.js +16 -6
- package/src/commands/skill.js +90 -24
- package/src/errors.js +28 -1
- package/src/help.js +52 -9
- package/src/helpers.js +95 -37
- package/src/paginated-search.js +178 -0
package/src/errors.js
CHANGED
|
@@ -79,7 +79,34 @@ export function errAPI(status, msg) {
|
|
|
79
79
|
if (status >= 500) {
|
|
80
80
|
hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
// Try to extract structured error detail from JSON API error bodies
|
|
84
|
+
let displayMsg = msg;
|
|
85
|
+
if (typeof msg === 'string' && (msg.startsWith('{') || msg.startsWith('['))) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(msg);
|
|
88
|
+
const errObj = parsed.error || parsed;
|
|
89
|
+
const detail = errObj.detail || errObj.message || '';
|
|
90
|
+
const message = errObj.message || '';
|
|
91
|
+
|
|
92
|
+
if (detail && message) {
|
|
93
|
+
displayMsg = `${message}: ${detail}`;
|
|
94
|
+
} else if (detail) {
|
|
95
|
+
displayMsg = detail;
|
|
96
|
+
} else if (message) {
|
|
97
|
+
displayMsg = message;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Detect business rule abortion and add a hint
|
|
101
|
+
if (detail && detail.includes('Business Rule')) {
|
|
102
|
+
hint = 'A Business Rule rejected this operation. Check for overlapping routes, validation rules, or ACLs on this table.';
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Not valid JSON, use original message
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new AppError(CodeAPI, `API error (status ${status}): ${displayMsg}`, hint, status);
|
|
83
110
|
}
|
|
84
111
|
|
|
85
112
|
export function errAmbiguous(resource, matches) {
|
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,71 +89,129 @@ 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
|
}
|
|
129
|
+
|
|
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
|
|
130
139
|
}
|
|
131
140
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Maps table name → array of field names that are computed by the platform.
|
|
143
|
+
* When a create/update payload contains these, a warning is emitted.
|
|
144
|
+
*/
|
|
145
|
+
export const DERIVED_FIELDS = {
|
|
146
|
+
sys_ws_operation: ['operation_uri'],
|
|
147
|
+
sys_ws_definition: ['base_uri'],
|
|
148
|
+
incident: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
|
|
149
|
+
change_request: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
|
|
150
|
+
// Generic — all sys_ fields are system-managed
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check a data payload for fields that appear to be derived/read-only.
|
|
155
|
+
* Returns an array of warning objects ({field, hint}) for any matches.
|
|
156
|
+
* @param {string} table - Table name (e.g. 'sys_ws_operation')
|
|
157
|
+
* @param {object} data - The JSON payload being sent
|
|
158
|
+
* @returns {Array<{field: string, hint: string}>}
|
|
159
|
+
*/
|
|
160
|
+
export function checkDerivedFields(table, data) {
|
|
161
|
+
if (!data || typeof data !== 'object') return [];
|
|
162
|
+
const warnings = [];
|
|
163
|
+
|
|
164
|
+
// Check explicitly known derived fields for this table
|
|
165
|
+
const knownFields = DERIVED_FIELDS[table] || [];
|
|
166
|
+
for (const field of knownFields) {
|
|
167
|
+
if (field in data && data[field] != null) {
|
|
168
|
+
warnings.push({
|
|
169
|
+
field,
|
|
170
|
+
hint: `${field} is a derived/read-only field. Setting it directly will be ignored.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Warn about any sys_* fields that look like system-managed metadata
|
|
176
|
+
// (but be careful — not all sys_ fields are read-only)
|
|
177
|
+
if (data.sys_created_on) {
|
|
178
|
+
warnings.push({
|
|
179
|
+
field: 'sys_created_on',
|
|
180
|
+
hint: 'sys_created_on is a system-managed field. Setting it directly will be ignored.',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (data.sys_updated_on) {
|
|
184
|
+
warnings.push({
|
|
185
|
+
field: 'sys_updated_on',
|
|
186
|
+
hint: 'sys_updated_on is a system-managed field. Setting it directly will be ignored.',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return warnings;
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
/**
|
|
138
|
-
* Parse --data or --data-
|
|
139
|
-
*
|
|
140
|
-
* Throws if
|
|
194
|
+
* Parse --data, --data-file, or --data-stdin into a JSON object.
|
|
195
|
+
* Priority: --data-file > --data-stdin > --data
|
|
196
|
+
* Throws if none is provided or JSON is invalid.
|
|
141
197
|
*/
|
|
142
198
|
export function parseDataArg(argv) {
|
|
143
199
|
let raw;
|
|
144
200
|
if (argv['data-file']) {
|
|
145
201
|
raw = fs.readFileSync(argv['data-file'], 'utf-8');
|
|
146
|
-
// Strip UTF-8 BOM (
|
|
202
|
+
// Strip UTF-8 BOM (\\ufeff) which some editors (Windows/PowerShell) add
|
|
147
203
|
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
204
|
+
} else if (argv['data-stdin']) {
|
|
205
|
+
raw = fs.readFileSync(process.stdin.fd, 'utf-8');
|
|
148
206
|
} else if (argv.data) {
|
|
149
207
|
raw = argv.data;
|
|
150
208
|
} else {
|
|
151
|
-
throw new Error('--data or --data-
|
|
209
|
+
throw new Error('--data, --data-file, or --data-stdin is required');
|
|
152
210
|
}
|
|
153
211
|
try {
|
|
154
212
|
return JSON.parse(raw);
|
|
155
213
|
} catch (e) {
|
|
156
|
-
throw new Error(`Invalid JSON: ${e.message}\n\nHint:
|
|
214
|
+
throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
|
|
157
215
|
}
|
|
158
216
|
}
|
|
159
217
|
|
|
@@ -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
|
+
}
|