@dboio/cli 0.6.14 → 0.8.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 +313 -8
- package/package.json +3 -2
- package/src/commands/add.js +56 -11
- package/src/commands/clone.js +1294 -75
- package/src/commands/content.js +1 -1
- package/src/commands/deploy.js +1 -1
- package/src/commands/init.js +39 -4
- package/src/commands/install.js +10 -1
- package/src/commands/login.js +4 -9
- package/src/commands/output.js +56 -3
- package/src/commands/pull.js +3 -3
- package/src/commands/push.js +24 -12
- package/src/lib/config.js +175 -8
- package/src/lib/diff.js +72 -14
- package/src/lib/ignore.js +145 -0
- package/src/lib/input-parser.js +87 -38
- package/src/lib/structure.js +130 -0
- package/src/lib/timestamps.js +31 -9
package/src/lib/diff.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { readFile, writeFile, readdir, access, stat } from 'fs/promises';
|
|
3
|
-
import { join, dirname, basename, extname } from 'path';
|
|
3
|
+
import { join, dirname, basename, extname, relative } from 'path';
|
|
4
|
+
import { loadIgnore } from './ignore.js';
|
|
4
5
|
import { parseServerDate, setFileTimestamps } from './timestamps.js';
|
|
5
6
|
import { loadConfig, loadUserInfo } from './config.js';
|
|
6
7
|
import { log } from './logger.js';
|
|
@@ -28,20 +29,26 @@ async function fileExists(path) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* Recursively find all
|
|
32
|
+
* Recursively find all metadata files in a directory.
|
|
33
|
+
* Includes .metadata.json files and output hierarchy files (_output~*.json).
|
|
32
34
|
*/
|
|
33
|
-
export async function findMetadataFiles(dir) {
|
|
35
|
+
export async function findMetadataFiles(dir, ig) {
|
|
36
|
+
if (!ig) ig = await loadIgnore();
|
|
37
|
+
|
|
34
38
|
const results = [];
|
|
35
39
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
36
40
|
|
|
37
41
|
for (const entry of entries) {
|
|
38
42
|
const fullPath = join(dir, entry.name);
|
|
39
43
|
if (entry.isDirectory()) {
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
results.push(...await findMetadataFiles(fullPath));
|
|
44
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
45
|
+
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
46
|
+
results.push(...await findMetadataFiles(fullPath, ig));
|
|
43
47
|
} else if (entry.name.endsWith('.metadata.json')) {
|
|
44
48
|
results.push(fullPath);
|
|
49
|
+
} else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
|
|
50
|
+
// Output hierarchy files: _output~<name>~<uid>.json and nested entity files
|
|
51
|
+
results.push(fullPath);
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -494,21 +501,67 @@ export async function applyServerChanges(diffResult, acceptedFields, config) {
|
|
|
494
501
|
}
|
|
495
502
|
}
|
|
496
503
|
|
|
504
|
+
// ─── Diffability ─────────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
// Extensions that can be meaningfully text-diffed
|
|
507
|
+
const DIFFABLE_EXTENSIONS = new Set([
|
|
508
|
+
'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
|
|
509
|
+
'css', 'scss', 'less', 'sass',
|
|
510
|
+
'html', 'htm', 'xhtml',
|
|
511
|
+
'sql', 'xml', 'json', 'yaml', 'yml', 'toml', 'ini', 'env',
|
|
512
|
+
'md', 'txt', 'csv', 'tsv',
|
|
513
|
+
'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
|
|
514
|
+
'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go', 'rs', 'swift', 'kt',
|
|
515
|
+
'vue', 'svelte', 'astro',
|
|
516
|
+
'graphql', 'gql', 'proto',
|
|
517
|
+
'htaccess', 'gitignore', 'dockerignore', 'editorconfig',
|
|
518
|
+
]);
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Returns true if the file extension suggests it can be meaningfully text-diffed.
|
|
522
|
+
* Images, videos, audio, fonts, archives, and other binary formats return false.
|
|
523
|
+
*/
|
|
524
|
+
export function isDiffable(ext) {
|
|
525
|
+
if (!ext) return false;
|
|
526
|
+
return DIFFABLE_EXTENSIONS.has(String(ext).toLowerCase().replace(/^\./, ''));
|
|
527
|
+
}
|
|
528
|
+
|
|
497
529
|
// ─── Change Detection Prompt ────────────────────────────────────────────────
|
|
498
530
|
|
|
499
531
|
/**
|
|
500
532
|
* Build the change detection message describing who changed the file.
|
|
501
533
|
*/
|
|
502
|
-
function buildChangeMessage(recordName, serverRecord, config) {
|
|
534
|
+
function buildChangeMessage(recordName, serverRecord, config, options = {}) {
|
|
503
535
|
const userInfo = loadUserInfoSync();
|
|
504
536
|
const updatedBy = serverRecord._LastUpdatedUserID;
|
|
505
537
|
|
|
538
|
+
let who;
|
|
506
539
|
if (updatedBy && userInfo && String(updatedBy) === String(userInfo.userId)) {
|
|
507
|
-
|
|
540
|
+
who = 'you (from another session)';
|
|
508
541
|
} else if (updatedBy) {
|
|
509
|
-
|
|
542
|
+
who = `user ${updatedBy}`;
|
|
510
543
|
}
|
|
511
|
-
|
|
544
|
+
|
|
545
|
+
const datePart = formatDateHint(options.serverDate, options.localDate);
|
|
546
|
+
|
|
547
|
+
if (who) {
|
|
548
|
+
return `"${recordName}" was updated on server by ${who}${datePart}`;
|
|
549
|
+
}
|
|
550
|
+
return `"${recordName}" has updates newer than your local version${datePart}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Format a "(server: X, local: Y)" hint when date info is available.
|
|
555
|
+
*/
|
|
556
|
+
function formatDateHint(serverDate, localDate) {
|
|
557
|
+
const fmt = (d) => d instanceof Date && !isNaN(d)
|
|
558
|
+
? d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
559
|
+
: null;
|
|
560
|
+
const s = fmt(serverDate);
|
|
561
|
+
const l = fmt(localDate);
|
|
562
|
+
if (s && l) return `\n server: ${s} | local: ${l}`;
|
|
563
|
+
if (s) return `\n server: ${s}`;
|
|
564
|
+
return '';
|
|
512
565
|
}
|
|
513
566
|
|
|
514
567
|
// Sync version for message building (cached)
|
|
@@ -520,31 +573,36 @@ function loadUserInfoSync() {
|
|
|
520
573
|
/**
|
|
521
574
|
* Prompt the user when a record has changed.
|
|
522
575
|
* options.localIsNewer: when true, the local file has modifications not on server.
|
|
576
|
+
* options.diffable: when false, omit the "Compare differences" choice.
|
|
577
|
+
* options.serverDate / options.localDate: Date objects shown as hints.
|
|
523
578
|
* Returns: 'overwrite' | 'compare' | 'skip' | 'overwrite_all' | 'skip_all'
|
|
524
579
|
*/
|
|
525
580
|
export async function promptChangeDetection(recordName, serverRecord, config, options = {}) {
|
|
526
581
|
const localIsNewer = options.localIsNewer || false;
|
|
582
|
+
const diffable = options.diffable !== false; // default true for text records
|
|
527
583
|
|
|
528
584
|
// Cache user info for message building
|
|
529
585
|
_cachedUserInfo = await loadUserInfo();
|
|
530
586
|
|
|
587
|
+
const datePart = formatDateHint(options.serverDate, options.localDate);
|
|
588
|
+
|
|
531
589
|
const message = localIsNewer
|
|
532
|
-
? `"${recordName}" has local changes not on the server`
|
|
533
|
-
: buildChangeMessage(recordName, serverRecord, config);
|
|
590
|
+
? `"${recordName}" has local changes not on the server${datePart}`
|
|
591
|
+
: buildChangeMessage(recordName, serverRecord, config, options);
|
|
534
592
|
|
|
535
593
|
const inquirer = (await import('inquirer')).default;
|
|
536
594
|
|
|
537
595
|
const choices = localIsNewer
|
|
538
596
|
? [
|
|
539
597
|
{ name: 'Restore server version (discard local changes)', value: 'overwrite' },
|
|
540
|
-
{ name: 'Compare differences (dbo diff)', value: 'compare' },
|
|
598
|
+
...(diffable ? [{ name: 'Compare differences (dbo diff)', value: 'compare' }] : []),
|
|
541
599
|
{ name: 'Keep local changes', value: 'skip' },
|
|
542
600
|
{ name: 'Restore all to server version', value: 'overwrite_all' },
|
|
543
601
|
{ name: 'Keep all local changes', value: 'skip_all' },
|
|
544
602
|
]
|
|
545
603
|
: [
|
|
546
604
|
{ name: 'Overwrite local file with server version', value: 'overwrite' },
|
|
547
|
-
{ name: 'Compare differences (dbo diff)', value: 'compare' },
|
|
605
|
+
...(diffable ? [{ name: 'Compare differences (dbo diff)', value: 'compare' }] : []),
|
|
548
606
|
{ name: 'Skip this file', value: 'skip' },
|
|
549
607
|
{ name: 'Overwrite this and all remaining changed files', value: 'overwrite_all' },
|
|
550
608
|
{ name: 'Skip all remaining changed files', value: 'skip_all' },
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readFile, writeFile, access } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
|
|
5
|
+
const DBOIGNORE_FILE = '.dboignore';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default .dboignore file content — shipped with `dbo init`.
|
|
9
|
+
* Gitignore-style syntax. Users can edit after creation.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
|
|
12
|
+
# (gitignore-style syntax — works like .gitignore)
|
|
13
|
+
|
|
14
|
+
# DBO internal
|
|
15
|
+
.dbo/
|
|
16
|
+
.dboignore
|
|
17
|
+
*.dboio.json
|
|
18
|
+
app.json
|
|
19
|
+
.app.json
|
|
20
|
+
dbo.deploy.json
|
|
21
|
+
|
|
22
|
+
# Editor / IDE
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.codekit3
|
|
26
|
+
|
|
27
|
+
# Version control
|
|
28
|
+
.git/
|
|
29
|
+
.svn/
|
|
30
|
+
.hg/
|
|
31
|
+
.gitignore
|
|
32
|
+
|
|
33
|
+
# AI / tooling
|
|
34
|
+
.claude/
|
|
35
|
+
.mcp.json
|
|
36
|
+
|
|
37
|
+
# Node
|
|
38
|
+
node_modules/
|
|
39
|
+
package.json
|
|
40
|
+
package-lock.json
|
|
41
|
+
|
|
42
|
+
# Documentation (repo scaffolding)
|
|
43
|
+
SETUP.md
|
|
44
|
+
README.md
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
// Session-level cache (one process = one command invocation)
|
|
48
|
+
let _cachedIg = null;
|
|
49
|
+
let _cachedRoot = null;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return the full default .dboignore file content (with comments).
|
|
53
|
+
*/
|
|
54
|
+
export function getDefaultFileContent() {
|
|
55
|
+
return DEFAULT_FILE_CONTENT;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract just the active pattern lines from DEFAULT_FILE_CONTENT.
|
|
60
|
+
*/
|
|
61
|
+
function getDefaultPatternLines() {
|
|
62
|
+
return DEFAULT_FILE_CONTENT
|
|
63
|
+
.split('\n')
|
|
64
|
+
.filter(l => l && !l.startsWith('#'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load and return a cached `ignore` instance for the current project.
|
|
69
|
+
* Reads .dboignore if it exists; falls back to built-in defaults.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} [cwd=process.cwd()] - Project root
|
|
72
|
+
* @returns {Promise<import('ignore').Ignore>}
|
|
73
|
+
*/
|
|
74
|
+
export async function loadIgnore(cwd = process.cwd()) {
|
|
75
|
+
if (_cachedIg && _cachedRoot === cwd) return _cachedIg;
|
|
76
|
+
|
|
77
|
+
const ig = ignore();
|
|
78
|
+
const filePath = join(cwd, DBOIGNORE_FILE);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const content = await readFile(filePath, 'utf8');
|
|
82
|
+
ig.add(content);
|
|
83
|
+
} catch {
|
|
84
|
+
// No .dboignore — use built-in defaults
|
|
85
|
+
ig.add(getDefaultPatternLines());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_cachedIg = ig;
|
|
89
|
+
_cachedRoot = cwd;
|
|
90
|
+
return ig;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check whether a relative path should be ignored.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} relativePath - Path relative to project root (forward slashes)
|
|
97
|
+
* @param {string} [cwd=process.cwd()]
|
|
98
|
+
* @returns {Promise<boolean>}
|
|
99
|
+
*/
|
|
100
|
+
export async function isIgnored(relativePath, cwd = process.cwd()) {
|
|
101
|
+
const ig = await loadIgnore(cwd);
|
|
102
|
+
return ig.ignores(relativePath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Filter an array of relative paths, returning only those NOT ignored.
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} paths - Relative paths
|
|
109
|
+
* @param {string} [cwd=process.cwd()]
|
|
110
|
+
* @returns {Promise<string[]>}
|
|
111
|
+
*/
|
|
112
|
+
export async function filterIgnored(paths, cwd = process.cwd()) {
|
|
113
|
+
const ig = await loadIgnore(cwd);
|
|
114
|
+
return ig.filter(paths);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create a .dboignore file with default patterns.
|
|
119
|
+
* Does nothing if the file already exists (unless force=true).
|
|
120
|
+
*
|
|
121
|
+
* @param {string} [cwd=process.cwd()]
|
|
122
|
+
* @param {{ force?: boolean }} [opts]
|
|
123
|
+
* @returns {Promise<boolean>} true if file was created/overwritten
|
|
124
|
+
*/
|
|
125
|
+
export async function createDboignore(cwd = process.cwd(), { force = false } = {}) {
|
|
126
|
+
const filePath = join(cwd, DBOIGNORE_FILE);
|
|
127
|
+
if (!force) {
|
|
128
|
+
try {
|
|
129
|
+
await access(filePath);
|
|
130
|
+
return false; // already exists
|
|
131
|
+
} catch { /* doesn't exist — create it */ }
|
|
132
|
+
}
|
|
133
|
+
await writeFile(filePath, DEFAULT_FILE_CONTENT);
|
|
134
|
+
_cachedIg = null;
|
|
135
|
+
_cachedRoot = null;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Reset the session cache. Call between tests that change cwd or .dboignore.
|
|
141
|
+
*/
|
|
142
|
+
export function resetCache() {
|
|
143
|
+
_cachedIg = null;
|
|
144
|
+
_cachedRoot = null;
|
|
145
|
+
}
|
package/src/lib/input-parser.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'fs/promises';
|
|
2
2
|
import { log } from './logger.js';
|
|
3
|
-
import { loadUserInfo } from './config.js';
|
|
3
|
+
import { loadUserInfo, loadTicketSuggestionOutput } from './config.js';
|
|
4
4
|
import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket } from './ticketing.js';
|
|
5
|
+
import { DboClient } from './client.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Parse DBO input syntax and build form data.
|
|
@@ -84,6 +85,7 @@ function findValueAtSign(expr) {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// Patterns that indicate a missing user identity in the server response
|
|
88
|
+
// Note: the user entity only has an ID (never a UID), so all patterns resolve to _OverrideUserID.
|
|
87
89
|
const USER_ID_PATTERNS = [
|
|
88
90
|
'LoggedInUser_UID',
|
|
89
91
|
'LoggedInUserID',
|
|
@@ -101,7 +103,7 @@ const USER_ID_PATTERNS = [
|
|
|
101
103
|
* - repo_mismatch → repository mismatch recovery (6 options)
|
|
102
104
|
* - ticket_lookup_required_error → prompts for Ticket ID (legacy)
|
|
103
105
|
* - LoggedInUser_UID, LoggedInUserID, CurrentUserID, UserID not found
|
|
104
|
-
* → prompts for User ID
|
|
106
|
+
* → prompts for User ID (session not authenticated)
|
|
105
107
|
*
|
|
106
108
|
* Returns an object with retry information, or null if no recoverable errors found.
|
|
107
109
|
*
|
|
@@ -109,9 +111,9 @@ const USER_ID_PATTERNS = [
|
|
|
109
111
|
* { retryParams, ticketExpressions, skipRecord, skipAll }
|
|
110
112
|
*
|
|
111
113
|
* Return shape for legacy/user errors (backward compatible):
|
|
112
|
-
* { _OverrideTicketID,
|
|
114
|
+
* { _OverrideTicketID, _OverrideUserID, ... }
|
|
113
115
|
*/
|
|
114
|
-
export async function checkSubmitErrors(result) {
|
|
116
|
+
export async function checkSubmitErrors(result, context = {}) {
|
|
115
117
|
const messages = result.messages || result.data?.Messages || [];
|
|
116
118
|
const allText = messages.filter(m => typeof m === 'string').join(' ');
|
|
117
119
|
|
|
@@ -121,7 +123,7 @@ export async function checkSubmitErrors(result) {
|
|
|
121
123
|
const hasRepoMismatch = allText.includes('repo_mismatch');
|
|
122
124
|
|
|
123
125
|
if (hasTicketError) {
|
|
124
|
-
return await handleTicketError(allText);
|
|
126
|
+
return await handleTicketError(allText, context);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
if (hasRepoMismatch) {
|
|
@@ -132,7 +134,6 @@ export async function checkSubmitErrors(result) {
|
|
|
132
134
|
const needsTicket = allText.includes('ticket_lookup_required_error');
|
|
133
135
|
const matchedUserPattern = USER_ID_PATTERNS.find(p => allText.includes(p));
|
|
134
136
|
const needsUser = !!matchedUserPattern;
|
|
135
|
-
const needsUserUid = matchedUserPattern && matchedUserPattern.includes('UID');
|
|
136
137
|
|
|
137
138
|
if (!needsTicket && !needsUser) return null;
|
|
138
139
|
|
|
@@ -142,14 +143,9 @@ export async function checkSubmitErrors(result) {
|
|
|
142
143
|
if (!process.stdin.isTTY) {
|
|
143
144
|
if (needsUser) {
|
|
144
145
|
const stored = await loadUserInfo();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (needsUserUid) {
|
|
149
|
-
retryParams['_OverrideUserUID'] = storedValue;
|
|
150
|
-
} else {
|
|
151
|
-
retryParams['_OverrideUserID'] = storedValue;
|
|
152
|
-
}
|
|
146
|
+
if (stored.userId) {
|
|
147
|
+
log.info(`Using stored user ID: ${stored.userId} (non-interactive mode)`);
|
|
148
|
+
retryParams['_OverrideUserID'] = stored.userId;
|
|
153
149
|
} else {
|
|
154
150
|
log.error(`This operation requires an authenticated user (${matchedUserPattern}).`);
|
|
155
151
|
log.dim(' Run "dbo login" first, or use an interactive terminal.');
|
|
@@ -167,41 +163,36 @@ export async function checkSubmitErrors(result) {
|
|
|
167
163
|
const prompts = [];
|
|
168
164
|
|
|
169
165
|
if (needsUser) {
|
|
170
|
-
const idType = needsUserUid ? 'UID' : 'ID';
|
|
171
166
|
log.warn(`This operation requires an authenticated user (${matchedUserPattern}).`);
|
|
172
167
|
log.dim(' Your session may have expired, or you may not be logged in.');
|
|
173
168
|
log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
|
|
174
169
|
|
|
175
170
|
const stored = await loadUserInfo();
|
|
176
|
-
const storedValue = needsUserUid ? stored.userUid : (stored.userId || stored.userUid);
|
|
177
|
-
const storedLabel = needsUserUid
|
|
178
|
-
? (stored.userUid ? `UID: ${stored.userUid}` : null)
|
|
179
|
-
: (stored.userId ? `ID: ${stored.userId}` : (stored.userUid ? `UID: ${stored.userUid}` : null));
|
|
180
171
|
|
|
181
|
-
if (
|
|
182
|
-
log.dim(` Stored session user ${
|
|
172
|
+
if (stored.userId) {
|
|
173
|
+
log.dim(` Stored session user ID: ${stored.userId}`);
|
|
183
174
|
prompts.push({
|
|
184
175
|
type: 'list',
|
|
185
176
|
name: 'userChoice',
|
|
186
|
-
message:
|
|
177
|
+
message: 'User ID Required:',
|
|
187
178
|
choices: [
|
|
188
|
-
{ name: `Use session user (${
|
|
189
|
-
{ name:
|
|
179
|
+
{ name: `Use session user (ID: ${stored.userId})`, value: stored.userId },
|
|
180
|
+
{ name: 'Enter a different User ID', value: '_custom' },
|
|
190
181
|
],
|
|
191
182
|
});
|
|
192
183
|
prompts.push({
|
|
193
184
|
type: 'input',
|
|
194
185
|
name: 'customUserValue',
|
|
195
|
-
message:
|
|
186
|
+
message: 'Custom User ID:',
|
|
196
187
|
when: (answers) => answers.userChoice === '_custom',
|
|
197
|
-
validate: v => v.trim() ? true :
|
|
188
|
+
validate: v => v.trim() ? true : 'User ID is required',
|
|
198
189
|
});
|
|
199
190
|
} else {
|
|
200
191
|
prompts.push({
|
|
201
192
|
type: 'input',
|
|
202
193
|
name: 'userValue',
|
|
203
|
-
message:
|
|
204
|
-
validate: v => v.trim() ? true :
|
|
194
|
+
message: 'User ID Required:',
|
|
195
|
+
validate: v => v.trim() ? true : 'User ID is required',
|
|
205
196
|
});
|
|
206
197
|
}
|
|
207
198
|
}
|
|
@@ -224,11 +215,7 @@ export async function checkSubmitErrors(result) {
|
|
|
224
215
|
const userValue = answers.userValue
|
|
225
216
|
|| (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
|
|
226
217
|
if (userValue) {
|
|
227
|
-
|
|
228
|
-
retryParams['_OverrideUserUID'] = userValue.trim();
|
|
229
|
-
} else {
|
|
230
|
-
retryParams['_OverrideUserID'] = userValue.trim();
|
|
231
|
-
}
|
|
218
|
+
retryParams['_OverrideUserID'] = userValue.trim();
|
|
232
219
|
}
|
|
233
220
|
|
|
234
221
|
return retryParams;
|
|
@@ -238,7 +225,7 @@ export async function checkSubmitErrors(result) {
|
|
|
238
225
|
* Handle ticket_error: Record update requires a Ticket ID but none was provided.
|
|
239
226
|
* Prompts the user with 4 recovery options.
|
|
240
227
|
*/
|
|
241
|
-
async function handleTicketError(allText) {
|
|
228
|
+
async function handleTicketError(allText, context = {}) {
|
|
242
229
|
// Try to extract record details from error text
|
|
243
230
|
const entityMatch = allText.match(/entity:(\w+)/);
|
|
244
231
|
const rowIdMatch = allText.match(/RowID:(\d+)/);
|
|
@@ -262,9 +249,63 @@ async function handleTicketError(allText) {
|
|
|
262
249
|
return null;
|
|
263
250
|
}
|
|
264
251
|
|
|
252
|
+
// Fetch ticket suggestions if configured
|
|
253
|
+
let suggestionChoices = [];
|
|
254
|
+
const ticketSuggestionUid = await loadTicketSuggestionOutput();
|
|
255
|
+
const rowUid = context.rowUid || uid || null;
|
|
256
|
+
|
|
257
|
+
if (ticketSuggestionUid && rowUid) {
|
|
258
|
+
try {
|
|
259
|
+
const client = new DboClient();
|
|
260
|
+
const suggestResult = await client.get(`/api/output/${ticketSuggestionUid}`, {
|
|
261
|
+
'_limit': '5',
|
|
262
|
+
'_rowcount': 'false',
|
|
263
|
+
'_template': 'json_raw',
|
|
264
|
+
'_filter@AssetUID': rowUid,
|
|
265
|
+
});
|
|
266
|
+
const rows = suggestResult.payload || suggestResult.data;
|
|
267
|
+
const rowArray = Array.isArray(rows) ? rows : (rows?.Rows || rows?.rows || []);
|
|
268
|
+
for (let i = 0; i < rowArray.length; i++) {
|
|
269
|
+
const row = rowArray[i];
|
|
270
|
+
const ticketId = row.TicketID || row.ticket_id;
|
|
271
|
+
const title = row.Title || row.Name || '';
|
|
272
|
+
const shortName = row.ShortName || row.short_name || '';
|
|
273
|
+
if (ticketId) {
|
|
274
|
+
const label = `${i + 1} (${ticketId}): ${title}${shortName ? ` [${shortName}]` : ''}`;
|
|
275
|
+
suggestionChoices.push({ name: label, value: String(ticketId) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
log.dim(` (Could not fetch ticket suggestions: ${err.message})`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
265
283
|
log.warn('This record update requires a Ticket ID.');
|
|
266
284
|
|
|
267
285
|
const inquirer = (await import('inquirer')).default;
|
|
286
|
+
|
|
287
|
+
// Build the ticket ID prompt — list with suggestions or manual input
|
|
288
|
+
const needsTicket = (a) => a.ticketAction === 'apply_one' || a.ticketAction === 'apply_all';
|
|
289
|
+
|
|
290
|
+
const ticketIdPrompt = suggestionChoices.length > 0
|
|
291
|
+
? {
|
|
292
|
+
type: 'list',
|
|
293
|
+
name: 'ticketId',
|
|
294
|
+
message: 'Select a Ticket ID:',
|
|
295
|
+
choices: [
|
|
296
|
+
...suggestionChoices,
|
|
297
|
+
{ name: 'Enter a Ticket ID manually\u2026', value: '_manual' },
|
|
298
|
+
],
|
|
299
|
+
when: needsTicket,
|
|
300
|
+
}
|
|
301
|
+
: {
|
|
302
|
+
type: 'input',
|
|
303
|
+
name: 'ticketId',
|
|
304
|
+
message: 'Enter Ticket ID:',
|
|
305
|
+
when: needsTicket,
|
|
306
|
+
validate: v => v.trim() ? true : 'Ticket ID is required',
|
|
307
|
+
};
|
|
308
|
+
|
|
268
309
|
const answers = await inquirer.prompt([
|
|
269
310
|
{
|
|
270
311
|
type: 'list',
|
|
@@ -277,11 +318,12 @@ async function handleTicketError(allText) {
|
|
|
277
318
|
{ name: 'Skip all updates that require a Ticket ID', value: 'skip_all' },
|
|
278
319
|
],
|
|
279
320
|
},
|
|
321
|
+
ticketIdPrompt,
|
|
280
322
|
{
|
|
281
323
|
type: 'input',
|
|
282
|
-
name: '
|
|
283
|
-
message: '
|
|
284
|
-
when: (a) => a
|
|
324
|
+
name: 'ticketIdManual',
|
|
325
|
+
message: 'Ticket ID:',
|
|
326
|
+
when: (a) => needsTicket(a) && a.ticketId === '_manual',
|
|
285
327
|
validate: v => v.trim() ? true : 'Ticket ID is required',
|
|
286
328
|
},
|
|
287
329
|
]);
|
|
@@ -293,7 +335,14 @@ async function handleTicketError(allText) {
|
|
|
293
335
|
return { skipAll: true };
|
|
294
336
|
}
|
|
295
337
|
|
|
296
|
-
const ticketId = answers.
|
|
338
|
+
const ticketId = (answers.ticketIdManual?.trim()) ||
|
|
339
|
+
(answers.ticketId !== '_manual' ? answers.ticketId?.trim() : null);
|
|
340
|
+
|
|
341
|
+
if (!ticketId) {
|
|
342
|
+
log.error(' No Ticket ID provided. Skipping record.');
|
|
343
|
+
return { skipRecord: true };
|
|
344
|
+
}
|
|
345
|
+
|
|
297
346
|
const ticketExpressions = [];
|
|
298
347
|
|
|
299
348
|
if (entity && rowId) {
|
package/src/lib/structure.js
CHANGED
|
@@ -19,6 +19,22 @@ export const DEFAULT_PROJECT_DIRS = [
|
|
|
19
19
|
'Integrations',
|
|
20
20
|
];
|
|
21
21
|
|
|
22
|
+
/** Map from physical output table names → documentation/display names */
|
|
23
|
+
export const OUTPUT_ENTITY_MAP = {
|
|
24
|
+
output: 'output',
|
|
25
|
+
output_value: 'column',
|
|
26
|
+
output_value_filter: 'filter',
|
|
27
|
+
output_value_entity_column_rel: 'join',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** All output hierarchy entity types (physical table names) */
|
|
31
|
+
export const OUTPUT_HIERARCHY_ENTITIES = [
|
|
32
|
+
'output',
|
|
33
|
+
'output_value',
|
|
34
|
+
'output_value_filter',
|
|
35
|
+
'output_value_entity_column_rel',
|
|
36
|
+
];
|
|
37
|
+
|
|
22
38
|
/** Map from server entity key → local project directory name */
|
|
23
39
|
export const ENTITY_DIR_MAP = {
|
|
24
40
|
extension: 'Extensions',
|
|
@@ -163,3 +179,117 @@ export function findBinByPath(dirPath, structure) {
|
|
|
163
179
|
export function findChildBins(binId, structure) {
|
|
164
180
|
return Object.values(structure).filter(e => e.parentBinID === binId);
|
|
165
181
|
}
|
|
182
|
+
|
|
183
|
+
// ─── Extension Descriptor Sub-directory Support ───────────────────────────
|
|
184
|
+
|
|
185
|
+
/** Root for all extension descriptor-grouped sub-directories */
|
|
186
|
+
export const EXTENSION_DESCRIPTORS_DIR = 'Extensions';
|
|
187
|
+
|
|
188
|
+
/** Extensions that cannot be mapped go here (always created, even if empty) */
|
|
189
|
+
export const EXTENSION_UNSUPPORTED_DIR = 'Extensions/Unsupported';
|
|
190
|
+
|
|
191
|
+
/** Root-level documentation directory for alternate placement */
|
|
192
|
+
export const DOCUMENTATION_DIR = 'Documentation';
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build a descriptor→dirName mapping from a flat array of extension records.
|
|
196
|
+
* Scans records where Descriptor === "descriptor_definition".
|
|
197
|
+
* Maps String1 (key) → Name (directory name).
|
|
198
|
+
*
|
|
199
|
+
* Rules:
|
|
200
|
+
* - Null/empty String1: skip, push to warnings[]
|
|
201
|
+
* - Null/empty Name: use String1 as the directory name
|
|
202
|
+
* - Duplicate String1: last one wins, push to warnings[]
|
|
203
|
+
*
|
|
204
|
+
* @param {Object[]} extensionRecords
|
|
205
|
+
* @returns {{ mapping: Object<string,string>, warnings: string[] }}
|
|
206
|
+
*/
|
|
207
|
+
export function buildDescriptorMapping(extensionRecords) {
|
|
208
|
+
const mapping = {};
|
|
209
|
+
const warnings = [];
|
|
210
|
+
|
|
211
|
+
for (const rec of extensionRecords) {
|
|
212
|
+
if (rec.Descriptor !== 'descriptor_definition') continue;
|
|
213
|
+
|
|
214
|
+
const rawKey = rec.String1;
|
|
215
|
+
// String1 may be a base64-encoded object from the server API
|
|
216
|
+
const key = resolveFieldValue(rawKey);
|
|
217
|
+
if (!key || String(key).trim() === '') {
|
|
218
|
+
warnings.push(`descriptor_definition UID=${rec.UID} has null/empty String1 — skipped`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const keyStr = String(key).trim();
|
|
222
|
+
|
|
223
|
+
const rawName = rec.Name;
|
|
224
|
+
const nameStr = resolveFieldValue(rawName);
|
|
225
|
+
const dirName = (nameStr && String(nameStr).trim())
|
|
226
|
+
? String(nameStr).trim()
|
|
227
|
+
: keyStr;
|
|
228
|
+
|
|
229
|
+
if (keyStr in mapping) {
|
|
230
|
+
warnings.push(`Duplicate descriptor_definition key "${keyStr}" — overwriting with UID=${rec.UID}`);
|
|
231
|
+
}
|
|
232
|
+
mapping[keyStr] = dirName;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { mapping, warnings };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a field value that may be a base64-encoded object from the server API.
|
|
240
|
+
* Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
|
|
241
|
+
*/
|
|
242
|
+
function resolveFieldValue(value) {
|
|
243
|
+
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
244
|
+
&& value.encoding === 'base64' && typeof value.value === 'string') {
|
|
245
|
+
return Buffer.from(value.value, 'base64').toString('utf8');
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Persist descriptorMapping and extensionDescriptorDirs into .dbo/structure.json.
|
|
252
|
+
* Extends the existing structure object (already contains bin entries).
|
|
253
|
+
*
|
|
254
|
+
* @param {Object} structure - Current structure from loadStructureFile()
|
|
255
|
+
* @param {Object} mapping - descriptor key → dir name
|
|
256
|
+
*/
|
|
257
|
+
export async function saveDescriptorMapping(structure, mapping) {
|
|
258
|
+
const descriptorDirs = [
|
|
259
|
+
EXTENSION_UNSUPPORTED_DIR,
|
|
260
|
+
...Object.values(mapping).map(name => `${EXTENSION_DESCRIPTORS_DIR}/${name}`),
|
|
261
|
+
];
|
|
262
|
+
const uniqueDirs = [...new Set(descriptorDirs)];
|
|
263
|
+
|
|
264
|
+
const extended = {
|
|
265
|
+
...structure,
|
|
266
|
+
descriptorMapping: mapping,
|
|
267
|
+
extensionDescriptorDirs: uniqueDirs,
|
|
268
|
+
};
|
|
269
|
+
await saveStructureFile(extended);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Load descriptorMapping from .dbo/structure.json.
|
|
274
|
+
* Returns {} if not yet persisted.
|
|
275
|
+
*/
|
|
276
|
+
export async function loadDescriptorMapping() {
|
|
277
|
+
const structure = await loadStructureFile();
|
|
278
|
+
return structure.descriptorMapping || {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Resolve the sub-directory path for a single extension record.
|
|
283
|
+
* Returns "Extensions/<MappedName>" or "Extensions/Unsupported".
|
|
284
|
+
*
|
|
285
|
+
* @param {Object} record - Extension record with a .Descriptor field
|
|
286
|
+
* @param {Object} mapping - descriptor key → dir name (from buildDescriptorMapping)
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
export function resolveExtensionSubDir(record, mapping) {
|
|
290
|
+
const descriptor = record.Descriptor;
|
|
291
|
+
if (!descriptor || !mapping[descriptor]) {
|
|
292
|
+
return EXTENSION_UNSUPPORTED_DIR;
|
|
293
|
+
}
|
|
294
|
+
return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
|
|
295
|
+
}
|