@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/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
- results.push(fullPath);
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
- results.push(fullPath);
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
+ }
@@ -16,19 +16,30 @@ export function formatResponse(result, options = {}) {
16
16
  }
17
17
 
18
18
  if (result.successful) {
19
- log.success(`Request successful ${chalk.dim(result.url || '')}`);
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(`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);
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('#'));
@@ -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
- retryParams['_OverrideUserID'] = userValue.trim();
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
+ }