@dboio/cli 0.4.1

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.
@@ -0,0 +1,310 @@
1
+ import chalk from 'chalk';
2
+ import { log } from './logger.js';
3
+
4
+ export function formatResponse(result, options = {}) {
5
+ // --jq mode: filter then print
6
+ if (options.jq) {
7
+ const filtered = jqFilter(result.data, options.jq);
8
+ printJson(filtered);
9
+ return;
10
+ }
11
+
12
+ // --json mode: colorized full JSON
13
+ if (options.json) {
14
+ printJson(result.data);
15
+ return;
16
+ }
17
+
18
+ if (result.successful) {
19
+ log.success(`Request successful ${chalk.dim(result.url || '')}`);
20
+ } else {
21
+ log.error(`Request failed ${chalk.dim(result.url || '')}`);
22
+ }
23
+
24
+ if (result.messages && result.messages.length > 0) {
25
+ for (const msg of result.messages) {
26
+ log.label('Message', msg);
27
+ }
28
+ }
29
+
30
+ if (result.payload) {
31
+ formatPayload(result.payload, options);
32
+ }
33
+ }
34
+
35
+ function formatPayload(payload, options = {}) {
36
+ if (!payload) return;
37
+
38
+ // Input API results
39
+ if (payload.Results) {
40
+ const r = payload.Results;
41
+ if (r.Add && r.Add.length > 0) {
42
+ log.info(`Added ${r.Add.length} record(s)`);
43
+ for (const rec of r.Add) {
44
+ const uid = rec.UID || rec.uid || '';
45
+ const id = rec.RowID || rec.rowid || rec.ID || '';
46
+ log.label(' UID', uid);
47
+ if (id) log.label(' RowID', id.toString());
48
+ }
49
+ }
50
+ if (r.Edit && r.Edit.length > 0) {
51
+ log.info(`Edited ${r.Edit.length} record(s)`);
52
+ }
53
+ if (r.Delete && r.Delete.length > 0) {
54
+ log.info(`Deleted ${r.Delete.length} record(s)`);
55
+ }
56
+ return;
57
+ }
58
+
59
+ // Output/query results — show row count or raw data
60
+ if (Array.isArray(payload)) {
61
+ log.info(`${payload.length} row(s) returned`);
62
+ if (payload.length > 0) {
63
+ formatTable(payload, options);
64
+ }
65
+ return;
66
+ }
67
+
68
+ // Generic object payload
69
+ if (typeof payload === 'object') {
70
+ printJson(payload);
71
+ } else {
72
+ log.plain(String(payload));
73
+ }
74
+ }
75
+
76
+ // Sensitive column names — values get masked
77
+ const SENSITIVE_COLUMNS = new Set([
78
+ 'password', 'passkey', 'secret', 'token', 'apikey', 'api_key',
79
+ 'secureconnectionstring', 'connectionstring', 'customencryption',
80
+ ]);
81
+
82
+ function formatTable(rows, options = {}) {
83
+ if (rows.length === 0) return;
84
+
85
+ const termWidth = process.stdout.columns || 120;
86
+ let allColumns = Object.keys(rows[0]);
87
+
88
+ // --columns filter: only show specified columns
89
+ if (options.columns) {
90
+ const picked = options.columns.split(',').map(c => c.trim());
91
+ allColumns = allColumns.filter(c => picked.some(p =>
92
+ c.toLowerCase() === p.toLowerCase()
93
+ ));
94
+ }
95
+
96
+ // Hide columns where ALL values are null/empty (unless explicitly requested)
97
+ if (!options.columns) {
98
+ allColumns = allColumns.filter(col => {
99
+ return rows.some(r => r[col] !== null && r[col] !== undefined && r[col] !== '');
100
+ });
101
+ }
102
+
103
+ if (allColumns.length === 0) return;
104
+
105
+ // Calculate column widths
106
+ const widths = {};
107
+ for (const col of allColumns) {
108
+ const maxData = Math.max(...rows.map(r => displayValue(r[col], col).length));
109
+ widths[col] = Math.max(col.length, Math.min(maxData, 30));
110
+ }
111
+
112
+ // Shrink columns to fit terminal width
113
+ const gap = 2;
114
+ let totalWidth = allColumns.reduce((sum, c) => sum + widths[c], 0) + (allColumns.length - 1) * gap;
115
+
116
+ if (totalWidth > termWidth && allColumns.length > 1) {
117
+ // First pass: shrink any column wider than 20 chars
118
+ while (totalWidth > termWidth) {
119
+ const widest = allColumns.reduce((a, b) => widths[a] > widths[b] ? a : b);
120
+ if (widths[widest] <= 6) break; // can't shrink further
121
+ widths[widest] = Math.max(6, widths[widest] - 1);
122
+ totalWidth = allColumns.reduce((sum, c) => sum + widths[c], 0) + (allColumns.length - 1) * gap;
123
+ }
124
+ }
125
+
126
+ // Header
127
+ const header = allColumns.map(c => c.substring(0, widths[c]).padEnd(widths[c])).join(' ');
128
+ log.plain(chalk.cyan(header));
129
+ log.plain(chalk.dim('─'.repeat(Math.min(header.length, termWidth))));
130
+
131
+ // Rows
132
+ for (const row of rows) {
133
+ const line = allColumns.map(c => {
134
+ const val = displayValue(row[c], c);
135
+ return val.substring(0, widths[c]).padEnd(widths[c]);
136
+ }).join(' ');
137
+ log.plain(line);
138
+ }
139
+ }
140
+
141
+ function displayValue(value, columnName) {
142
+ if (value === null || value === undefined) return '';
143
+
144
+ // Mask sensitive columns
145
+ if (SENSITIVE_COLUMNS.has(columnName.toLowerCase())) {
146
+ const str = String(value);
147
+ if (str.length === 0) return '';
148
+ return str.substring(0, 3) + '***';
149
+ }
150
+
151
+ const str = String(value);
152
+
153
+ // Truncate very long values (base64, blobs, etc.)
154
+ if (str.length > 60) {
155
+ return str.substring(0, 57) + '...';
156
+ }
157
+
158
+ return str;
159
+ }
160
+
161
+ /**
162
+ * Lightweight jq-style JSON filter.
163
+ *
164
+ * Supported expressions:
165
+ * . identity
166
+ * .key access key
167
+ * .key.nested nested access
168
+ * .key[].field iterate array, pluck field
169
+ * .[0] array index
170
+ * .[] iterate array
171
+ * .[] | .field pipe: iterate then access
172
+ * .["key.with.dots"] bracket notation
173
+ */
174
+ export function jqFilter(data, expr) {
175
+ if (!expr || expr.trim() === '.') return data;
176
+
177
+ // Handle pipe: .[] | .field
178
+ if (expr.includes(' | ')) {
179
+ const [left, right] = expr.split(' | ', 2);
180
+ const intermediate = jqFilter(data, left.trim());
181
+ if (Array.isArray(intermediate)) {
182
+ return intermediate.map(item => jqFilter(item, right.trim()));
183
+ }
184
+ return jqFilter(intermediate, right.trim());
185
+ }
186
+
187
+ let current = data;
188
+ // Tokenize the expression: split by . but respect brackets
189
+ const tokens = tokenize(expr);
190
+
191
+ for (const token of tokens) {
192
+ if (current === undefined || current === null) return null;
193
+
194
+ // Array iteration: []
195
+ if (token === '[]') {
196
+ if (!Array.isArray(current)) return null;
197
+ // If there are more tokens after [], we need to map
198
+ continue;
199
+ }
200
+
201
+ // Array iteration with remaining path: [].field
202
+ if (token.startsWith('[]')) {
203
+ if (!Array.isArray(current)) return null;
204
+ const remaining = token.substring(2);
205
+ current = current.map(item => jqFilter(item, '.' + remaining));
206
+ continue;
207
+ }
208
+
209
+ // Array index: [0], [1], etc.
210
+ const indexMatch = token.match(/^\[(\d+)]$/);
211
+ if (indexMatch) {
212
+ const idx = parseInt(indexMatch[1], 10);
213
+ current = Array.isArray(current) ? current[idx] : null;
214
+ continue;
215
+ }
216
+
217
+ // Bracket notation: ["key.name"]
218
+ const bracketMatch = token.match(/^\["(.+)"]$/);
219
+ if (bracketMatch) {
220
+ current = current[bracketMatch[1]];
221
+ continue;
222
+ }
223
+
224
+ // Plain key access
225
+ if (typeof current === 'object' && current !== null) {
226
+ current = current[token];
227
+ } else {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ // If current is still an array and we had [], flatten single-field plucks
233
+ return current;
234
+ }
235
+
236
+ function tokenize(expr) {
237
+ const tokens = [];
238
+ let i = expr.startsWith('.') ? 1 : 0; // skip leading dot
239
+
240
+ while (i < expr.length) {
241
+ if (expr[i] === '.') {
242
+ i++;
243
+ continue;
244
+ }
245
+
246
+ if (expr[i] === '[') {
247
+ // Find matching ]
248
+ const end = expr.indexOf(']', i);
249
+ if (end === -1) break;
250
+ let token = expr.substring(i, end + 1);
251
+ // Check if there's more after ] (like [].field)
252
+ if (end + 1 < expr.length && expr[end + 1] === '.') {
253
+ // Collect remaining path segment for array iteration
254
+ const nextDot = expr.indexOf('.', end + 2);
255
+ const nextBracket = expr.indexOf('[', end + 2);
256
+ let segEnd;
257
+ if (nextDot === -1 && nextBracket === -1) segEnd = expr.length;
258
+ else if (nextDot === -1) segEnd = nextBracket;
259
+ else if (nextBracket === -1) segEnd = nextDot;
260
+ else segEnd = Math.min(nextDot, nextBracket);
261
+ token += expr.substring(end + 1, segEnd);
262
+ i = segEnd;
263
+ } else {
264
+ i = end + 1;
265
+ }
266
+ tokens.push(token);
267
+ continue;
268
+ }
269
+
270
+ // Regular key: read until next . or [
271
+ let end = i;
272
+ while (end < expr.length && expr[end] !== '.' && expr[end] !== '[') end++;
273
+ tokens.push(expr.substring(i, end));
274
+ i = end;
275
+ }
276
+
277
+ return tokens;
278
+ }
279
+
280
+ /**
281
+ * Print JSON with syntax highlighting.
282
+ */
283
+ function printJson(data) {
284
+ const json = JSON.stringify(data, null, 2);
285
+ if (!json) { log.plain('null'); return; }
286
+
287
+ const colored = json.replace(
288
+ /("(?:[^"\\]|\\.)*")\s*:/g, // keys
289
+ (match, key) => chalk.cyan(key) + ':'
290
+ ).replace(
291
+ /:\s*("(?:[^"\\]|\\.)*")/g, // string values
292
+ (match, val) => ': ' + chalk.green(val)
293
+ ).replace(
294
+ /:\s*(\d+\.?\d*)/g, // numbers
295
+ (match, num) => ': ' + chalk.yellow(num)
296
+ ).replace(
297
+ /:\s*(true|false|null)/g, // booleans/null
298
+ (match, val) => ': ' + chalk.magenta(val)
299
+ );
300
+
301
+ log.plain(colored);
302
+ }
303
+
304
+ export function formatError(err) {
305
+ if (err.message) {
306
+ log.error(err.message);
307
+ } else {
308
+ log.error(String(err));
309
+ }
310
+ }
@@ -0,0 +1,212 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { log } from './logger.js';
3
+ import { loadUserInfo } from './config.js';
4
+
5
+ /**
6
+ * Parse DBO input syntax and build form data.
7
+ *
8
+ * Syntax: RowID:add1;column:entity.Column=value
9
+ * RowUID:abc123;column:content.Content@filepath
10
+ * RowID:del123;entity:user=true
11
+ *
12
+ * The @filepath syntax means "read file contents and use as value" (URL-encoded, not multipart).
13
+ */
14
+
15
+ export async function buildInputBody(dataExpressions, extraParams = {}) {
16
+ const parts = [];
17
+
18
+ // Add extra params first (_confirm, _OverrideTicketID, etc.)
19
+ for (const [key, value] of Object.entries(extraParams)) {
20
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
21
+ }
22
+
23
+ for (const expr of dataExpressions) {
24
+ // Split by & to handle multiple ops in one -d string
25
+ const ops = expr.split('&');
26
+ for (const op of ops) {
27
+ const encoded = await encodeInputExpression(op.trim());
28
+ parts.push(encoded);
29
+ }
30
+ }
31
+
32
+ return parts.join('&');
33
+ }
34
+
35
+ async function encodeInputExpression(expr) {
36
+ // Check if value part contains @filepath
37
+ // Format: key=value or key@filepath
38
+ // The key is everything before the first = or @ that acts as value separator
39
+ // DBO syntax: RowID:add1;column:content.Content@path/to/file.css
40
+ // RowID:add1;column:user.Name=John
41
+
42
+ // Find the value separator: last @ or = in the expression
43
+ // But we need to be careful: the @ in column:entity.Column@file is the value separator,
44
+ // while : and ; are part of the DBO key syntax
45
+
46
+ // Strategy: find the position of the first = or the last @ that comes after a column reference
47
+ const eqIdx = expr.indexOf('=');
48
+ const atIdx = findValueAtSign(expr);
49
+
50
+ if (atIdx !== -1 && (eqIdx === -1 || atIdx < eqIdx)) {
51
+ // @filepath syntax: read file and URL-encode
52
+ const key = expr.substring(0, atIdx);
53
+ const filePath = expr.substring(atIdx + 1);
54
+ const content = await readFile(filePath, 'utf8');
55
+ return `${encodeURIComponent(key)}=${encodeURIComponent(content)}`;
56
+ } else if (eqIdx !== -1) {
57
+ // =value syntax: direct value
58
+ const key = expr.substring(0, eqIdx);
59
+ const value = expr.substring(eqIdx + 1);
60
+ return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
61
+ } else {
62
+ // No value separator, encode the whole thing
63
+ return encodeURIComponent(expr);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Find the @ sign that acts as a value separator (file reference),
69
+ * not the @ in email addresses or other contexts.
70
+ *
71
+ * In DBO syntax, the @ follows the pattern:
72
+ * column:entity.ColumnName@filepath
73
+ * So it comes after a dot-separated entity.column reference.
74
+ */
75
+ function findValueAtSign(expr) {
76
+ // Look for @ that appears after column:entity.Column pattern
77
+ // The pattern is: ...column:word.Word@...
78
+ const match = expr.match(/;column:[a-zA-Z_]+\.[a-zA-Z_]+@/);
79
+ if (match) {
80
+ return expr.indexOf('@', match.index);
81
+ }
82
+ return -1;
83
+ }
84
+
85
+ // Patterns that indicate a missing user identity in the server response
86
+ const USER_ID_PATTERNS = [
87
+ 'LoggedInUser_UID',
88
+ 'LoggedInUserID',
89
+ 'CurrentUserID',
90
+ 'CurrentUser_UID',
91
+ 'UserID',
92
+ ];
93
+
94
+ /**
95
+ * Check if a submission result contains recoverable errors that can be
96
+ * resolved by prompting the user for missing parameters.
97
+ *
98
+ * Detects:
99
+ * - ticket_lookup_required_error → prompts for Ticket ID
100
+ * - LoggedInUser_UID, LoggedInUserID, CurrentUserID, UserID not found
101
+ * → prompts for User ID or UID (session not authenticated)
102
+ *
103
+ * Returns an object of extra params to add on retry, or null if no
104
+ * recoverable errors were found.
105
+ */
106
+ export async function checkSubmitErrors(result) {
107
+ const messages = result.messages || result.data?.Messages || [];
108
+ const allText = messages.filter(m => typeof m === 'string').join(' ');
109
+
110
+ const needsTicket = allText.includes('ticket_lookup_required_error');
111
+
112
+ // Detect which user identity variant is needed
113
+ const matchedUserPattern = USER_ID_PATTERNS.find(p => allText.includes(p));
114
+ const needsUser = !!matchedUserPattern;
115
+ const needsUserUid = matchedUserPattern && matchedUserPattern.includes('UID');
116
+ const needsUserId = matchedUserPattern && !needsUserUid;
117
+
118
+ if (!needsTicket && !needsUser) return null;
119
+
120
+ const prompts = [];
121
+ const retryParams = {};
122
+
123
+ if (needsUser) {
124
+ const idType = needsUserUid ? 'UID' : 'ID';
125
+ log.warn(`This operation requires an authenticated user (${matchedUserPattern}).`);
126
+ log.dim(' Your session may have expired, or you may not be logged in.');
127
+ log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
128
+
129
+ // Check for stored user info from a previous login
130
+ const stored = await loadUserInfo();
131
+ const storedValue = needsUserUid ? stored.userUid : (stored.userId || stored.userUid);
132
+ const storedLabel = needsUserUid
133
+ ? (stored.userUid ? `UID: ${stored.userUid}` : null)
134
+ : (stored.userId ? `ID: ${stored.userId}` : (stored.userUid ? `UID: ${stored.userUid}` : null));
135
+
136
+ if (storedValue) {
137
+ log.dim(` Stored session user ${storedLabel}`);
138
+ prompts.push({
139
+ type: 'list',
140
+ name: 'userChoice',
141
+ message: `User ${idType} Required:`,
142
+ choices: [
143
+ { name: `Use session user (${storedLabel})`, value: storedValue },
144
+ { name: `Enter a different User ${idType}`, value: '_custom' },
145
+ ],
146
+ });
147
+ prompts.push({
148
+ type: 'input',
149
+ name: 'customUserValue',
150
+ message: `Custom User ${idType}:`,
151
+ when: (answers) => answers.userChoice === '_custom',
152
+ validate: v => v.trim() ? true : `User ${idType} is required`,
153
+ });
154
+ } else {
155
+ prompts.push({
156
+ type: 'input',
157
+ name: 'userValue',
158
+ message: `User ${idType} Required:`,
159
+ validate: v => v.trim() ? true : `User ${idType} is required`,
160
+ });
161
+ }
162
+ }
163
+
164
+ if (needsTicket) {
165
+ log.warn('This operation requires a Ticket ID.');
166
+ prompts.push({
167
+ type: 'input',
168
+ name: 'ticketId',
169
+ message: 'Ticket ID Required:',
170
+ validate: v => v.trim() ? true : 'Ticket ID is required',
171
+ });
172
+ }
173
+
174
+ const inquirer = (await import('inquirer')).default;
175
+ const answers = await inquirer.prompt(prompts);
176
+
177
+ if (answers.ticketId) retryParams['_OverrideTicketID'] = answers.ticketId.trim();
178
+
179
+ // Resolve user identity from choice or direct input
180
+ const userValue = answers.userValue
181
+ || (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
182
+ if (userValue) {
183
+ // Use the appropriate override param based on what the server asked for
184
+ if (needsUserUid) {
185
+ retryParams['_OverrideUserUID'] = userValue.trim();
186
+ } else {
187
+ retryParams['_OverrideUserID'] = userValue.trim();
188
+ }
189
+ }
190
+
191
+ return retryParams;
192
+ }
193
+
194
+ /**
195
+ * Parse file arguments in the format: field=@path or just @path
196
+ * Returns objects suitable for multipart upload.
197
+ */
198
+ export function parseFileArg(arg) {
199
+ if (arg.includes('=@')) {
200
+ const [field, path] = arg.split('=@', 2);
201
+ const fileName = path.split('/').pop();
202
+ return { fieldName: field, filePath: path, fileName };
203
+ }
204
+ if (arg.startsWith('@')) {
205
+ const path = arg.substring(1);
206
+ const fileName = path.split('/').pop();
207
+ return { fieldName: 'file', filePath: path, fileName };
208
+ }
209
+ // Treat as: file=@path
210
+ const fileName = arg.split('/').pop();
211
+ return { fieldName: 'file', filePath: arg, fileName };
212
+ }
@@ -0,0 +1,12 @@
1
+ import chalk from 'chalk';
2
+
3
+ export const log = {
4
+ info(msg) { console.log(chalk.blue('ℹ'), msg); },
5
+ success(msg) { console.log(chalk.green('✓'), msg); },
6
+ warn(msg) { console.log(chalk.yellow('⚠'), msg); },
7
+ error(msg) { console.error(chalk.red('✗'), msg); },
8
+ dim(msg) { console.log(chalk.dim(msg)); },
9
+ plain(msg) { console.log(msg); },
10
+ verbose(msg) { console.log(chalk.dim(' →'), chalk.dim(msg)); },
11
+ label(label, value) { console.log(chalk.dim(` ${label}:`), value); },
12
+ };