@dboio/cli 0.8.0 → 0.9.2
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 +157 -57
- package/package.json +1 -1
- package/src/commands/add.js +122 -10
- package/src/commands/clone.js +351 -99
- package/src/commands/deploy.js +1 -1
- package/src/commands/init.js +13 -4
- package/src/commands/input.js +2 -2
- package/src/commands/login.js +69 -0
- package/src/commands/pull.js +1 -0
- package/src/commands/push.js +202 -34
- package/src/commands/rm.js +48 -16
- package/src/lib/delta.js +14 -1
- package/src/lib/diff.js +4 -2
- package/src/lib/filenames.js +151 -0
- package/src/lib/formatter.js +93 -11
- package/src/lib/ignore.js +8 -2
- package/src/lib/input-parser.js +30 -4
- package/src/lib/metadata-templates.js +264 -0
- package/src/lib/structure.js +30 -26
- package/src/lib/ticketing.js +79 -8
package/src/lib/delta.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'fs/promises';
|
|
1
|
+
import { readFile, stat } from 'fs/promises';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { log } from './logger.js';
|
|
4
4
|
|
|
@@ -132,6 +132,19 @@ export async function detectChangedColumns(metaPath, baseline) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Check _mediaFile for binary file changes (media entities)
|
|
136
|
+
if (metadata._mediaFile && isReference(metadata._mediaFile)) {
|
|
137
|
+
const mediaPath = resolveReferencePath(metadata._mediaFile, metaDir);
|
|
138
|
+
try {
|
|
139
|
+
const mediaStat = await stat(mediaPath);
|
|
140
|
+
const metaStat = await stat(metaPath);
|
|
141
|
+
// Media file modified more recently than metadata = local change
|
|
142
|
+
if (mediaStat.mtimeMs > metaStat.mtimeMs + 2000) {
|
|
143
|
+
changedColumns.push('_mediaFile');
|
|
144
|
+
}
|
|
145
|
+
} catch { /* missing file, skip */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
return changedColumns;
|
|
136
149
|
}
|
|
137
150
|
|
package/src/lib/diff.js
CHANGED
|
@@ -45,10 +45,12 @@ export async function findMetadataFiles(dir, ig) {
|
|
|
45
45
|
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
46
46
|
results.push(...await findMetadataFiles(fullPath, ig));
|
|
47
47
|
} else if (entry.name.endsWith('.metadata.json')) {
|
|
48
|
-
|
|
48
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
49
|
+
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
49
50
|
} else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
|
|
50
51
|
// Output hierarchy files: _output~<name>~<uid>.json and nested entity files
|
|
51
|
-
|
|
52
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
53
|
+
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filename convention helpers for the UID tilde convention.
|
|
3
|
+
* All entity files use <basename>~<uid>.<ext> as the local filename.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { rename, writeFile } from 'fs/promises';
|
|
7
|
+
import { join, dirname, basename, extname } from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build a UID-bearing filename following the tilde convention.
|
|
11
|
+
* If name === uid, omit the tilde suffix (avoids <uid>~<uid>).
|
|
12
|
+
*
|
|
13
|
+
* @param {string} name - Sanitized base name (no extension)
|
|
14
|
+
* @param {string} uid - Server-assigned UID
|
|
15
|
+
* @param {string} [ext] - File extension WITHOUT leading dot (e.g. 'css', 'png', '')
|
|
16
|
+
* @returns {string} - e.g. "colors~abc123.css" or "abc123.css" or "colors~abc123"
|
|
17
|
+
*/
|
|
18
|
+
export function buildUidFilename(name, uid, ext = '') {
|
|
19
|
+
const base = (name === uid) ? uid : `${name}~${uid}`;
|
|
20
|
+
return ext ? `${base}.${ext}` : base;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip the ~<uid> portion from a local filename.
|
|
25
|
+
* Used when the local file is "logo~def456.png" but the upload should send "logo.png".
|
|
26
|
+
*
|
|
27
|
+
* If the uid is not found in the filename, returns localName unchanged.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} localName - e.g. "logo~def456.png"
|
|
30
|
+
* @param {string} uid - e.g. "def456"
|
|
31
|
+
* @returns {string} - e.g. "logo.png"
|
|
32
|
+
*/
|
|
33
|
+
export function stripUidFromFilename(localName, uid) {
|
|
34
|
+
if (!uid || !localName) return localName;
|
|
35
|
+
const marker = `~${uid}`;
|
|
36
|
+
const idx = localName.indexOf(marker);
|
|
37
|
+
if (idx === -1) return localName;
|
|
38
|
+
return localName.slice(0, idx) + localName.slice(idx + marker.length);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check whether a filename already contains ~<uid>.
|
|
43
|
+
*/
|
|
44
|
+
export function hasUidInFilename(filename, uid) {
|
|
45
|
+
return typeof filename === 'string' && typeof uid === 'string'
|
|
46
|
+
&& filename.includes(`~${uid}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Detect whether a metadata filename uses the OLD dot-separated convention:
|
|
51
|
+
* <name>.<uid>.metadata.json
|
|
52
|
+
* where uid is a sequence of ≥10 lowercase alphanumeric characters.
|
|
53
|
+
*
|
|
54
|
+
* Returns { name, uid } or null.
|
|
55
|
+
*/
|
|
56
|
+
export function detectLegacyDotUid(filename) {
|
|
57
|
+
const match = filename.match(/^(.+)\.([a-z0-9]{10,})\.metadata\.json$/);
|
|
58
|
+
return match ? { name: match[1], uid: match[2] } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Rename a file pair (content + metadata) to the ~uid convention after server assigns a UID.
|
|
63
|
+
* Updates @reference values inside the metadata file.
|
|
64
|
+
* Restores file timestamps from _LastUpdated.
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} meta - Current metadata object
|
|
67
|
+
* @param {string} metaPath - Absolute path to the .metadata.json file
|
|
68
|
+
* @param {string} uid - Newly assigned UID from server
|
|
69
|
+
* @param {string} lastUpdated - Server _LastUpdated value
|
|
70
|
+
* @param {string} serverTz - Timezone for timestamp conversion
|
|
71
|
+
* @returns {Promise<{ newMetaPath: string, newFilePath: string|null }>}
|
|
72
|
+
*/
|
|
73
|
+
export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
|
|
74
|
+
const metaDir = dirname(metaPath);
|
|
75
|
+
const metaBase = basename(metaPath, '.metadata.json'); // e.g. "colors" or "logo.png"
|
|
76
|
+
|
|
77
|
+
if (hasUidInFilename(metaBase, uid)) {
|
|
78
|
+
return { newMetaPath: metaPath, newFilePath: null }; // already renamed
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// For media files: metaBase = "logo.png", newBase = "logo~uid.png"
|
|
82
|
+
// For content files: metaBase = "colors", newBase = "colors~uid"
|
|
83
|
+
const metaBaseExt = extname(metaBase); // ".png" for media metadata, "" for content metadata
|
|
84
|
+
let newMetaBase;
|
|
85
|
+
if (metaBaseExt) {
|
|
86
|
+
// Media: "logo.png" → "logo~uid.png"
|
|
87
|
+
const nameWithoutExt = metaBase.slice(0, -metaBaseExt.length);
|
|
88
|
+
newMetaBase = `${buildUidFilename(nameWithoutExt, uid)}${metaBaseExt}`;
|
|
89
|
+
} else {
|
|
90
|
+
// Content/entity-dir: "colors" → "colors~uid"
|
|
91
|
+
newMetaBase = buildUidFilename(metaBase, uid);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const newMetaPath = join(metaDir, `${newMetaBase}.metadata.json`);
|
|
95
|
+
|
|
96
|
+
// Update metadata in memory
|
|
97
|
+
meta.UID = uid;
|
|
98
|
+
const updatedMeta = { ...meta };
|
|
99
|
+
|
|
100
|
+
// Rename content files referenced in _contentColumns and _mediaFile
|
|
101
|
+
const contentCols = [...(meta._contentColumns || [])];
|
|
102
|
+
if (meta._mediaFile) contentCols.push('_mediaFile');
|
|
103
|
+
|
|
104
|
+
let newFilePath = null;
|
|
105
|
+
|
|
106
|
+
for (const col of contentCols) {
|
|
107
|
+
const ref = meta[col];
|
|
108
|
+
if (!ref || !String(ref).startsWith('@')) continue;
|
|
109
|
+
|
|
110
|
+
const oldRefFile = String(ref).substring(1);
|
|
111
|
+
const oldFilePath = join(metaDir, oldRefFile);
|
|
112
|
+
|
|
113
|
+
// Compute new filename: insert uid
|
|
114
|
+
const oldExt = extname(oldRefFile); // ".css", ".png"
|
|
115
|
+
const oldBase = basename(oldRefFile, oldExt); // "colors" or "logo"
|
|
116
|
+
const newRefBase = buildUidFilename(oldBase, uid);
|
|
117
|
+
const newRefFile = oldExt ? `${newRefBase}${oldExt}` : newRefBase;
|
|
118
|
+
const newRefPath = join(metaDir, newRefFile);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await rename(oldFilePath, newRefPath);
|
|
122
|
+
updatedMeta[col] = `@${newRefFile}`;
|
|
123
|
+
if (!newFilePath) newFilePath = newRefPath;
|
|
124
|
+
} catch {
|
|
125
|
+
// File may already be renamed or may not exist
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Write updated metadata to new path
|
|
130
|
+
await writeFile(newMetaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
|
|
131
|
+
|
|
132
|
+
// Remove old metadata file if it's different from newMetaPath
|
|
133
|
+
if (metaPath !== newMetaPath) {
|
|
134
|
+
try { await rename(metaPath, newMetaPath); } catch { /* already written above */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Restore timestamps
|
|
138
|
+
if (serverTz && lastUpdated) {
|
|
139
|
+
const { setFileTimestamps } = await import('./timestamps.js');
|
|
140
|
+
for (const col of contentCols) {
|
|
141
|
+
const ref = updatedMeta[col];
|
|
142
|
+
if (ref && String(ref).startsWith('@')) {
|
|
143
|
+
const fp = join(metaDir, String(ref).substring(1));
|
|
144
|
+
try { await setFileTimestamps(fp, lastUpdated, lastUpdated, serverTz); } catch {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
try { await setFileTimestamps(newMetaPath, lastUpdated, lastUpdated, serverTz); } catch {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { newMetaPath, newFilePath, updatedMeta };
|
|
151
|
+
}
|
package/src/lib/formatter.js
CHANGED
|
@@ -16,19 +16,30 @@ export function formatResponse(result, options = {}) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
if (result.successful) {
|
|
19
|
-
log.success(
|
|
19
|
+
log.success('Request successful');
|
|
20
|
+
// Show the payload as compact highlighted JSON so the user sees what changed
|
|
21
|
+
if (result.payload) {
|
|
22
|
+
printJsonCompact(result.payload);
|
|
23
|
+
}
|
|
20
24
|
} else {
|
|
21
|
-
log.error(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
log.error('Request failed');
|
|
26
|
+
if (result.messages && result.messages.length > 0) {
|
|
27
|
+
for (const msg of result.messages) {
|
|
28
|
+
log.label('Message', truncateParamValues(String(msg)));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// On failure: show compact response summary; full JSON only with --verbose
|
|
32
|
+
if (result.data) {
|
|
33
|
+
if (options.verbose) {
|
|
34
|
+
log.plain('');
|
|
35
|
+
log.dim('─── Response JSON ───');
|
|
36
|
+
printJson(result.data);
|
|
37
|
+
} else {
|
|
38
|
+
log.plain('');
|
|
39
|
+
log.dim('─── Response (use -v for full JSON) ───');
|
|
40
|
+
printJsonCompact(result.data);
|
|
41
|
+
}
|
|
27
42
|
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (result.payload) {
|
|
31
|
-
formatPayload(result.payload, options);
|
|
32
43
|
}
|
|
33
44
|
}
|
|
34
45
|
|
|
@@ -301,6 +312,77 @@ function printJson(data) {
|
|
|
301
312
|
log.plain(colored);
|
|
302
313
|
}
|
|
303
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Print JSON with syntax highlighting, truncating long string values.
|
|
317
|
+
* Strings over 120 chars are collapsed to a short preview + byte count.
|
|
318
|
+
*/
|
|
319
|
+
function printJsonCompact(data) {
|
|
320
|
+
const json = JSON.stringify(data, (key, value) => {
|
|
321
|
+
if (typeof value === 'string' && value.length > 120) {
|
|
322
|
+
const preview = value.substring(0, 60).replace(/\n/g, '\\n');
|
|
323
|
+
const kb = (value.length / 1024).toFixed(1);
|
|
324
|
+
return `${preview}… (${kb} KB)`;
|
|
325
|
+
}
|
|
326
|
+
return value;
|
|
327
|
+
}, 2);
|
|
328
|
+
if (!json) { log.plain('null'); return; }
|
|
329
|
+
|
|
330
|
+
const colored = json.replace(
|
|
331
|
+
/("(?:[^"\\]|\\.)*")\s*:/g,
|
|
332
|
+
(match, k) => chalk.cyan(k) + ':'
|
|
333
|
+
).replace(
|
|
334
|
+
/:\s*("(?:[^"\\]|\\.)*")/g,
|
|
335
|
+
(match, val) => ': ' + chalk.green(val)
|
|
336
|
+
).replace(
|
|
337
|
+
/:\s*(\d+\.?\d*)/g,
|
|
338
|
+
(match, num) => ': ' + chalk.yellow(num)
|
|
339
|
+
).replace(
|
|
340
|
+
/:\s*(true|false|null)/g,
|
|
341
|
+
(match, val) => ': ' + chalk.magenta(val)
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
log.plain(colored);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Truncate long parameter values in server error messages.
|
|
349
|
+
* SQL debug output contains lines like " @Content = <entire HTML file>" —
|
|
350
|
+
* collapse those so the terminal isn't flooded.
|
|
351
|
+
*
|
|
352
|
+
* Strategy: split on lines, find " @Param = value" lines where the value
|
|
353
|
+
* portion (everything after " = ") spans many characters, and collapse.
|
|
354
|
+
*/
|
|
355
|
+
function truncateParamValues(text) {
|
|
356
|
+
const lines = text.split('\n');
|
|
357
|
+
const result = [];
|
|
358
|
+
let i = 0;
|
|
359
|
+
|
|
360
|
+
while (i < lines.length) {
|
|
361
|
+
const paramMatch = lines[i].match(/^(\s*@\w+\s*=\s*)(.*)/);
|
|
362
|
+
if (paramMatch) {
|
|
363
|
+
const prefix = paramMatch[1]; // " @Content = "
|
|
364
|
+
// Collect continuation lines (lines that don't start a new @param or section marker)
|
|
365
|
+
let value = paramMatch[2];
|
|
366
|
+
while (i + 1 < lines.length && !lines[i + 1].match(/^\s*@\w+\s*=/) && !lines[i + 1].match(/^\[/)) {
|
|
367
|
+
i++;
|
|
368
|
+
value += '\n' + lines[i];
|
|
369
|
+
}
|
|
370
|
+
if (value.length > 120) {
|
|
371
|
+
const preview = value.substring(0, 60).replace(/\n/g, ' ').trim();
|
|
372
|
+
const kb = (value.length / 1024).toFixed(1);
|
|
373
|
+
result.push(`${prefix}${preview}... (${kb} KB)`);
|
|
374
|
+
} else {
|
|
375
|
+
result.push(`${prefix}${value}`);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
result.push(lines[i]);
|
|
379
|
+
}
|
|
380
|
+
i++;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return result.join('\n');
|
|
384
|
+
}
|
|
385
|
+
|
|
304
386
|
export function formatError(err) {
|
|
305
387
|
if (err.message) {
|
|
306
388
|
log.error(err.message);
|
package/src/lib/ignore.js
CHANGED
|
@@ -19,7 +19,9 @@ app.json
|
|
|
19
19
|
.app.json
|
|
20
20
|
dbo.deploy.json
|
|
21
21
|
|
|
22
|
-
# Editor / IDE
|
|
22
|
+
# Editor / IDE / OS
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
23
25
|
.idea/
|
|
24
26
|
.vscode/
|
|
25
27
|
*.codekit3
|
|
@@ -39,6 +41,10 @@ node_modules/
|
|
|
39
41
|
package.json
|
|
40
42
|
package-lock.json
|
|
41
43
|
|
|
44
|
+
# Local development (not pushed to DBO server)
|
|
45
|
+
src/
|
|
46
|
+
tests/
|
|
47
|
+
|
|
42
48
|
# Documentation (repo scaffolding)
|
|
43
49
|
SETUP.md
|
|
44
50
|
README.md
|
|
@@ -58,7 +64,7 @@ export function getDefaultFileContent() {
|
|
|
58
64
|
/**
|
|
59
65
|
* Extract just the active pattern lines from DEFAULT_FILE_CONTENT.
|
|
60
66
|
*/
|
|
61
|
-
function getDefaultPatternLines() {
|
|
67
|
+
export function getDefaultPatternLines() {
|
|
62
68
|
return DEFAULT_FILE_CONTENT
|
|
63
69
|
.split('\n')
|
|
64
70
|
.filter(l => l && !l.startsWith('#'));
|
package/src/lib/input-parser.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { readFile } from 'fs/promises';
|
|
2
2
|
import { log } from './logger.js';
|
|
3
3
|
import { loadUserInfo, loadTicketSuggestionOutput } from './config.js';
|
|
4
|
-
import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket } from './ticketing.js';
|
|
4
|
+
import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket, setTicketingRequired } from './ticketing.js';
|
|
5
5
|
import { DboClient } from './client.js';
|
|
6
6
|
|
|
7
|
+
// Session-level cache: once the user resolves a UserID prompt, reuse it for all
|
|
8
|
+
// subsequent records in the same CLI invocation (one process = one push batch).
|
|
9
|
+
let _sessionUserOverride = null;
|
|
10
|
+
|
|
11
|
+
/** Get cached UserID override (set after first interactive prompt). */
|
|
12
|
+
export function getSessionUserOverride() {
|
|
13
|
+
return _sessionUserOverride;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reset session caches (for tests). */
|
|
17
|
+
export function resetSessionCache() {
|
|
18
|
+
_sessionUserOverride = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
/**
|
|
8
22
|
* Parse DBO input syntax and build form data.
|
|
9
23
|
*
|
|
@@ -160,9 +174,16 @@ export async function checkSubmitErrors(result, context = {}) {
|
|
|
160
174
|
return Object.keys(retryParams).length > 0 ? retryParams : null;
|
|
161
175
|
}
|
|
162
176
|
|
|
177
|
+
// Session cache: reuse UserID from a previous prompt in this batch
|
|
178
|
+
const userResolved = needsUser && _sessionUserOverride;
|
|
179
|
+
if (userResolved) {
|
|
180
|
+
retryParams['_OverrideUserID'] = _sessionUserOverride;
|
|
181
|
+
log.dim(` Using cached user ID: ${_sessionUserOverride}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
163
184
|
const prompts = [];
|
|
164
185
|
|
|
165
|
-
if (needsUser) {
|
|
186
|
+
if (needsUser && !userResolved) {
|
|
166
187
|
log.warn(`This operation requires an authenticated user (${matchedUserPattern}).`);
|
|
167
188
|
log.dim(' Your session may have expired, or you may not be logged in.');
|
|
168
189
|
log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
|
|
@@ -215,7 +236,9 @@ export async function checkSubmitErrors(result, context = {}) {
|
|
|
215
236
|
const userValue = answers.userValue
|
|
216
237
|
|| (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
|
|
217
238
|
if (userValue) {
|
|
218
|
-
|
|
239
|
+
const resolved = userValue.trim();
|
|
240
|
+
retryParams['_OverrideUserID'] = resolved;
|
|
241
|
+
_sessionUserOverride = resolved; // cache for remaining records in this batch
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
return retryParams;
|
|
@@ -226,6 +249,9 @@ export async function checkSubmitErrors(result, context = {}) {
|
|
|
226
249
|
* Prompts the user with 4 recovery options.
|
|
227
250
|
*/
|
|
228
251
|
async function handleTicketError(allText, context = {}) {
|
|
252
|
+
// Mark this app as requiring ticketing (detected on first ticket_error)
|
|
253
|
+
await setTicketingRequired();
|
|
254
|
+
|
|
229
255
|
// Try to extract record details from error text
|
|
230
256
|
const entityMatch = allText.match(/entity:(\w+)/);
|
|
231
257
|
const rowIdMatch = allText.match(/RowID:(\d+)/);
|
|
@@ -312,8 +338,8 @@ async function handleTicketError(allText, context = {}) {
|
|
|
312
338
|
name: 'ticketAction',
|
|
313
339
|
message: 'Record update requires a Ticket ID:',
|
|
314
340
|
choices: [
|
|
315
|
-
{ name: 'Apply a Ticket ID to this record and resubmit', value: 'apply_one' },
|
|
316
341
|
{ name: 'Apply a Ticket ID to all updates in this transaction, and update my current Ticket ID reference', value: 'apply_all' },
|
|
342
|
+
{ name: 'Apply a Ticket ID to this record only', value: 'apply_one' },
|
|
317
343
|
{ name: 'Skip this record update', value: 'skip_one' },
|
|
318
344
|
{ name: 'Skip all updates that require a Ticket ID', value: 'skip_all' },
|
|
319
345
|
],
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { relative, basename, extname } from 'path';
|
|
3
|
+
import { EXTENSION_DESCRIPTORS_DIR, ENTITY_DIR_NAMES } from './structure.js';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
|
|
6
|
+
const METADATA_TEMPLATES_FILE = '.dbo/metadata_templates.json';
|
|
7
|
+
|
|
8
|
+
const STATIC_DIRECTIVE_MAP = {
|
|
9
|
+
docs: { entity: 'extension', descriptor: 'documentation' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert a name to snake_case.
|
|
14
|
+
*/
|
|
15
|
+
export function toSnakeCase(name) {
|
|
16
|
+
return name
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[\s-]+/g, '_')
|
|
19
|
+
.replace(/[^a-z0-9_]/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve entity/descriptor directive from a file path.
|
|
24
|
+
* Returns { entity, descriptor } or null.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDirective(filePath) {
|
|
27
|
+
const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
28
|
+
const parts = rel.split('/');
|
|
29
|
+
const topDir = parts[0];
|
|
30
|
+
|
|
31
|
+
// docs/ prefix → static mapping
|
|
32
|
+
if (topDir === 'docs') {
|
|
33
|
+
return { ...STATIC_DIRECTIVE_MAP.docs };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// extension/<descriptor>/ — need at least 3 parts (extension/descriptor/file)
|
|
37
|
+
if (topDir === 'extension') {
|
|
38
|
+
if (parts.length < 3) return null;
|
|
39
|
+
const secondDir = parts[1];
|
|
40
|
+
if (secondDir.startsWith('.')) return null;
|
|
41
|
+
return { entity: 'extension', descriptor: secondDir };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Other entity-dir types
|
|
45
|
+
if (ENTITY_DIR_NAMES.has(topDir)) {
|
|
46
|
+
return { entity: topDir, descriptor: null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load metadata templates from .dbo/metadata_templates.json.
|
|
54
|
+
*/
|
|
55
|
+
export async function loadMetadataTemplates() {
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readFile(METADATA_TEMPLATES_FILE, 'utf8');
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
console.warn('Warning: .dbo/metadata_templates.json is malformed JSON — falling back to generic wizard');
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === 'ENOENT') return {};
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Save metadata templates to .dbo/metadata_templates.json.
|
|
72
|
+
*/
|
|
73
|
+
export async function saveMetadataTemplates(templates) {
|
|
74
|
+
await writeFile(METADATA_TEMPLATES_FILE, JSON.stringify(templates, null, 2) + '\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get template cols for a given entity and optional descriptor.
|
|
79
|
+
* Returns string[] or null.
|
|
80
|
+
*/
|
|
81
|
+
export function getTemplateCols(templates, entity, descriptor) {
|
|
82
|
+
if (!templates || !templates[entity]) return null;
|
|
83
|
+
const entry = templates[entity];
|
|
84
|
+
|
|
85
|
+
if (descriptor) {
|
|
86
|
+
// entry must be an object (not array)
|
|
87
|
+
if (Array.isArray(entry)) return null;
|
|
88
|
+
return entry[descriptor] ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// no descriptor — entry must be an array
|
|
92
|
+
if (!Array.isArray(entry)) return null;
|
|
93
|
+
return entry;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Set template cols for a given entity and optional descriptor.
|
|
98
|
+
* Mutates templates in place.
|
|
99
|
+
*/
|
|
100
|
+
export function setTemplateCols(templates, entity, descriptor, cols) {
|
|
101
|
+
if (descriptor) {
|
|
102
|
+
if (!templates[entity] || Array.isArray(templates[entity])) {
|
|
103
|
+
templates[entity] = {};
|
|
104
|
+
}
|
|
105
|
+
templates[entity][descriptor] = cols;
|
|
106
|
+
} else {
|
|
107
|
+
templates[entity] = cols;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build cols from a record's keys, excluding _ prefixed and array values.
|
|
113
|
+
*/
|
|
114
|
+
function buildColsFromRecord(record) {
|
|
115
|
+
return Object.keys(record).filter(key => {
|
|
116
|
+
if (key.startsWith('_')) return false;
|
|
117
|
+
if (Array.isArray(record[key])) return false;
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build baseline minimum viable cols for an entity type.
|
|
124
|
+
*/
|
|
125
|
+
function buildBaselineCols(entity, descriptor) {
|
|
126
|
+
const cols = ['AppID', 'Name'];
|
|
127
|
+
if (entity === 'extension') {
|
|
128
|
+
cols.push('ShortName');
|
|
129
|
+
if (descriptor) cols.push(`Descriptor=${descriptor}`);
|
|
130
|
+
cols.push('Active');
|
|
131
|
+
}
|
|
132
|
+
return cols;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve template cols via three-level lookup:
|
|
137
|
+
* 1. metadata_templates.json
|
|
138
|
+
* 2. appJson sample record
|
|
139
|
+
* 3. baseline defaults
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveTemplateCols(entity, descriptor, appConfig, appJson) {
|
|
142
|
+
const templates = await loadMetadataTemplates();
|
|
143
|
+
if (templates === null) return null;
|
|
144
|
+
|
|
145
|
+
// Level 1: existing template
|
|
146
|
+
const existing = getTemplateCols(templates, entity, descriptor);
|
|
147
|
+
if (existing) return { cols: existing, templates, isNew: false };
|
|
148
|
+
|
|
149
|
+
// Level 2: derive from appJson
|
|
150
|
+
if (appJson) {
|
|
151
|
+
let records = null;
|
|
152
|
+
|
|
153
|
+
if (entity === 'extension' && descriptor) {
|
|
154
|
+
// appJson[entity] might be an object keyed by descriptor
|
|
155
|
+
const entityData = appJson[entity];
|
|
156
|
+
if (entityData) {
|
|
157
|
+
if (Array.isArray(entityData)) {
|
|
158
|
+
records = entityData;
|
|
159
|
+
} else if (typeof entityData === 'object' && entityData[descriptor]) {
|
|
160
|
+
const descData = entityData[descriptor];
|
|
161
|
+
if (Array.isArray(descData)) records = descData;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
const entityData = appJson[entity];
|
|
166
|
+
if (Array.isArray(entityData)) records = entityData;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (records && records.length > 0) {
|
|
170
|
+
const cols = buildColsFromRecord(records[0]);
|
|
171
|
+
setTemplateCols(templates, entity, descriptor, cols);
|
|
172
|
+
await saveMetadataTemplates(templates);
|
|
173
|
+
return { cols, templates, isNew: true };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Level 3: baseline defaults
|
|
178
|
+
const cols = buildBaselineCols(entity, descriptor);
|
|
179
|
+
setTemplateCols(templates, entity, descriptor, cols);
|
|
180
|
+
await saveMetadataTemplates(templates);
|
|
181
|
+
return { cols, templates, isNew: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Assemble metadata object from cols and file info.
|
|
186
|
+
*/
|
|
187
|
+
export function assembleMetadata(cols, filePath, entity, descriptor, appConfig) {
|
|
188
|
+
const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
189
|
+
const base = basename(filePath, extname(filePath));
|
|
190
|
+
const fileName = basename(filePath);
|
|
191
|
+
const isDocsFile = rel.startsWith('docs/');
|
|
192
|
+
const meta = { _entity: entity };
|
|
193
|
+
const contentColumns = [];
|
|
194
|
+
|
|
195
|
+
for (const col of cols) {
|
|
196
|
+
if (col.includes('=')) {
|
|
197
|
+
const eqIdx = col.indexOf('=');
|
|
198
|
+
const key = col.substring(0, eqIdx);
|
|
199
|
+
const val = col.substring(eqIdx + 1);
|
|
200
|
+
|
|
201
|
+
if (val === '@reference') {
|
|
202
|
+
const refPath = isDocsFile ? '@/' + rel : '@' + fileName;
|
|
203
|
+
meta[key] = refPath;
|
|
204
|
+
contentColumns.push(key);
|
|
205
|
+
} else {
|
|
206
|
+
meta[key] = val;
|
|
207
|
+
}
|
|
208
|
+
} else if (col === 'AppID') {
|
|
209
|
+
meta.AppID = appConfig?.AppID ?? '';
|
|
210
|
+
} else if (col === 'Name') {
|
|
211
|
+
meta.Name = base;
|
|
212
|
+
} else if (col === 'ShortName') {
|
|
213
|
+
meta.ShortName = toSnakeCase(base);
|
|
214
|
+
} else {
|
|
215
|
+
meta[col] = '';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let refColMissing = false;
|
|
220
|
+
if (contentColumns.length === 0) {
|
|
221
|
+
refColMissing = true;
|
|
222
|
+
} else {
|
|
223
|
+
meta._contentColumns = contentColumns;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { meta, contentColumns, refColMissing };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Prompt user to select a reference column from available string cols.
|
|
231
|
+
*/
|
|
232
|
+
export async function promptReferenceColumn(cols, entity, descriptor) {
|
|
233
|
+
const stringCols = cols.filter(c => !c.includes('=') && c !== 'AppID' && c !== 'Name' && c !== 'ShortName');
|
|
234
|
+
if (stringCols.length === 0) return null;
|
|
235
|
+
|
|
236
|
+
const { selected } = await inquirer.prompt([{
|
|
237
|
+
type: 'list',
|
|
238
|
+
name: 'selected',
|
|
239
|
+
message: `Select the content/reference column for ${entity}${descriptor ? '/' + descriptor : ''}:`,
|
|
240
|
+
choices: stringCols,
|
|
241
|
+
}]);
|
|
242
|
+
|
|
243
|
+
return selected;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build a template cols array from a clone record.
|
|
248
|
+
*/
|
|
249
|
+
export function buildTemplateFromCloneRecord(record, contentColsExtracted = []) {
|
|
250
|
+
const cols = [];
|
|
251
|
+
for (const key of Object.keys(record)) {
|
|
252
|
+
if (key.startsWith('_')) continue;
|
|
253
|
+
if (Array.isArray(record[key])) continue;
|
|
254
|
+
|
|
255
|
+
if (contentColsExtracted.includes(key)) {
|
|
256
|
+
cols.push(key + '=@reference');
|
|
257
|
+
} else if (key === 'Descriptor' && record[key]) {
|
|
258
|
+
cols.push('Descriptor=' + record[key]);
|
|
259
|
+
} else {
|
|
260
|
+
cols.push(key);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return cols;
|
|
264
|
+
}
|