@dboio/cli 0.15.2 → 0.16.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 +103 -25
- package/package.json +1 -1
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +103 -25
- package/src/commands/add.js +18 -18
- package/src/commands/clone.js +390 -157
- package/src/commands/init.js +42 -1
- package/src/commands/input.js +2 -32
- package/src/commands/mv.js +3 -3
- package/src/commands/push.js +29 -11
- package/src/commands/rm.js +2 -2
- package/src/lib/columns.js +1 -0
- package/src/lib/config.js +83 -1
- package/src/lib/delta.js +31 -7
- package/src/lib/dependencies.js +217 -2
- package/src/lib/diff.js +9 -11
- package/src/lib/filenames.js +2 -2
- package/src/lib/ignore.js +1 -0
- package/src/lib/logger.js +35 -0
- package/src/lib/metadata-schema.js +492 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/schema.js +53 -0
- package/src/lib/structure.js +3 -3
- package/src/lib/tagging.js +1 -1
- package/src/lib/ticketing.js +18 -2
- package/src/lib/toe-stepping.js +9 -6
- package/src/migrations/007-natural-entity-companion-filenames.js +5 -2
- package/src/migrations/009-fix-media-collision-metadata-names.js +161 -0
- package/src/migrations/010-delete-paren-media-orphans.js +61 -0
- package/src/migrations/011-schema-driven-metadata.js +120 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, access } from 'fs/promises';
|
|
2
|
+
import { join, relative, basename, extname } from 'path';
|
|
3
|
+
import { EXTENSION_DESCRIPTORS_DIR, ENTITY_DIR_NAMES } from './structure.js';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
|
|
6
|
+
export const METADATA_SCHEMA_FILE = '.dbo/metadata_schema.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 at project root → static mapping (docs/ stays at root)
|
|
32
|
+
if (topDir === 'docs') {
|
|
33
|
+
return { ...STATIC_DIRECTIVE_MAP.docs };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// lib/<subDir>/... — all server-managed dirs are now under lib/
|
|
37
|
+
if (topDir === 'lib') {
|
|
38
|
+
const subDir = parts[1];
|
|
39
|
+
if (!subDir) return null;
|
|
40
|
+
|
|
41
|
+
// lib/extension/<descriptor>/<file> — need at least 4 parts
|
|
42
|
+
if (subDir === 'extension') {
|
|
43
|
+
if (parts.length < 4) return null;
|
|
44
|
+
const descriptorDir = parts[2];
|
|
45
|
+
if (descriptorDir.startsWith('.')) return null;
|
|
46
|
+
return { entity: 'extension', descriptor: descriptorDir };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// lib/<entityType>/<file> — other entity dirs
|
|
50
|
+
if (ENTITY_DIR_NAMES.has(subDir)) {
|
|
51
|
+
return { entity: subDir, descriptor: null };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Legacy fallback: bare entity-dir paths (pre-migration projects)
|
|
56
|
+
if (topDir === 'extension') {
|
|
57
|
+
if (parts.length < 3) return null;
|
|
58
|
+
const secondDir = parts[1];
|
|
59
|
+
if (secondDir.startsWith('.')) return null;
|
|
60
|
+
return { entity: 'extension', descriptor: secondDir };
|
|
61
|
+
}
|
|
62
|
+
if (ENTITY_DIR_NAMES.has(topDir)) {
|
|
63
|
+
return { entity: topDir, descriptor: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load metadata schema from .dbo/metadata_schema.json.
|
|
71
|
+
*/
|
|
72
|
+
export async function loadMetadataSchema() {
|
|
73
|
+
try {
|
|
74
|
+
const raw = await readFile(METADATA_SCHEMA_FILE, 'utf8');
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(raw);
|
|
77
|
+
} catch {
|
|
78
|
+
console.warn('Warning: .dbo/metadata_schema.json is malformed JSON — falling back to generic wizard');
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err.code === 'ENOENT') return {};
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Save metadata schema to .dbo/metadata_schema.json.
|
|
89
|
+
*/
|
|
90
|
+
export async function saveMetadataSchema(templates) {
|
|
91
|
+
await writeFile(METADATA_SCHEMA_FILE, JSON.stringify(templates, null, 2) + '\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Merge extension descriptor definitions from dependency metadata_schema.json files.
|
|
96
|
+
* For each dependency in .dbo/dependencies/<name>/, reads its .dbo/metadata_schema.json
|
|
97
|
+
* and copies any extension descriptor entries that don't already exist locally.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} localSchema - The current project's metadata_schema object (mutated in place)
|
|
100
|
+
* @returns {Promise<boolean>} - true if any entries were merged
|
|
101
|
+
*/
|
|
102
|
+
export async function mergeDescriptorSchemaFromDependencies(localSchema) {
|
|
103
|
+
if (!localSchema) return false;
|
|
104
|
+
|
|
105
|
+
const depsRoot = join('.dbo', 'dependencies');
|
|
106
|
+
let depNames;
|
|
107
|
+
try {
|
|
108
|
+
depNames = await readdir(depsRoot);
|
|
109
|
+
} catch {
|
|
110
|
+
return false; // no dependencies directory
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let merged = false;
|
|
114
|
+
|
|
115
|
+
for (const depName of depNames) {
|
|
116
|
+
const depSchemaPath = join(depsRoot, depName, '.dbo', 'metadata_schema.json');
|
|
117
|
+
let depSchema;
|
|
118
|
+
try {
|
|
119
|
+
depSchema = JSON.parse(await readFile(depSchemaPath, 'utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
continue; // no schema or unreadable
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Only merge extension descriptor entries (the dict values under "extension")
|
|
125
|
+
const depExtension = depSchema.extension;
|
|
126
|
+
if (!depExtension || typeof depExtension !== 'object' || Array.isArray(depExtension)) continue;
|
|
127
|
+
|
|
128
|
+
for (const [descriptor, cols] of Object.entries(depExtension)) {
|
|
129
|
+
// Skip if local already has this descriptor
|
|
130
|
+
const existing = getTemplateCols(localSchema, 'extension', descriptor);
|
|
131
|
+
if (existing) continue;
|
|
132
|
+
|
|
133
|
+
setTemplateCols(localSchema, 'extension', descriptor, cols);
|
|
134
|
+
merged = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return merged;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get template cols for a given entity and optional descriptor.
|
|
143
|
+
* Returns string[] or null.
|
|
144
|
+
*/
|
|
145
|
+
export function getTemplateCols(templates, entity, descriptor) {
|
|
146
|
+
if (!templates || !templates[entity]) return null;
|
|
147
|
+
const entry = templates[entity];
|
|
148
|
+
|
|
149
|
+
if (descriptor) {
|
|
150
|
+
// entry must be an object (not array)
|
|
151
|
+
if (Array.isArray(entry)) return null;
|
|
152
|
+
return entry[descriptor] ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// no descriptor — entry must be an array
|
|
156
|
+
if (!Array.isArray(entry)) return null;
|
|
157
|
+
return entry;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Set template cols for a given entity and optional descriptor.
|
|
162
|
+
* Mutates templates in place.
|
|
163
|
+
*/
|
|
164
|
+
export function setTemplateCols(templates, entity, descriptor, cols) {
|
|
165
|
+
if (descriptor) {
|
|
166
|
+
if (!templates[entity] || Array.isArray(templates[entity])) {
|
|
167
|
+
templates[entity] = {};
|
|
168
|
+
}
|
|
169
|
+
templates[entity][descriptor] = cols;
|
|
170
|
+
} else {
|
|
171
|
+
templates[entity] = cols;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build cols from a record's keys, excluding _ prefixed and array values.
|
|
177
|
+
*/
|
|
178
|
+
function buildColsFromRecord(record) {
|
|
179
|
+
return Object.keys(record).filter(key => {
|
|
180
|
+
if (key.startsWith('_')) return false;
|
|
181
|
+
if (Array.isArray(record[key])) return false;
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Build baseline minimum viable cols for an entity type.
|
|
188
|
+
*/
|
|
189
|
+
function buildBaselineCols(entity, descriptor) {
|
|
190
|
+
const cols = ['AppID', 'Name'];
|
|
191
|
+
if (entity === 'extension') {
|
|
192
|
+
cols.push('ShortName');
|
|
193
|
+
if (descriptor) cols.push(`Descriptor=${descriptor}`);
|
|
194
|
+
cols.push('Active');
|
|
195
|
+
}
|
|
196
|
+
return cols;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolve template cols via three-level lookup:
|
|
201
|
+
* 1. metadata_schema.json
|
|
202
|
+
* 2. appJson sample record
|
|
203
|
+
* 3. baseline defaults
|
|
204
|
+
*/
|
|
205
|
+
export async function resolveTemplateCols(entity, descriptor, appConfig, appJson) {
|
|
206
|
+
const templates = await loadMetadataSchema();
|
|
207
|
+
if (templates === null) return null;
|
|
208
|
+
|
|
209
|
+
// Level 1: existing template
|
|
210
|
+
const existing = getTemplateCols(templates, entity, descriptor);
|
|
211
|
+
if (existing) return { cols: existing, templates, isNew: false };
|
|
212
|
+
|
|
213
|
+
// Level 2: derive from appJson
|
|
214
|
+
if (appJson) {
|
|
215
|
+
let records = null;
|
|
216
|
+
|
|
217
|
+
if (entity === 'extension' && descriptor) {
|
|
218
|
+
// appJson[entity] might be an object keyed by descriptor
|
|
219
|
+
const entityData = appJson[entity];
|
|
220
|
+
if (entityData) {
|
|
221
|
+
if (Array.isArray(entityData)) {
|
|
222
|
+
records = entityData;
|
|
223
|
+
} else if (typeof entityData === 'object' && entityData[descriptor]) {
|
|
224
|
+
const descData = entityData[descriptor];
|
|
225
|
+
if (Array.isArray(descData)) records = descData;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
const entityData = appJson[entity];
|
|
230
|
+
if (Array.isArray(entityData)) records = entityData;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (records && records.length > 0) {
|
|
234
|
+
const cols = buildColsFromRecord(records[0]);
|
|
235
|
+
setTemplateCols(templates, entity, descriptor, cols);
|
|
236
|
+
await saveMetadataSchema(templates);
|
|
237
|
+
return { cols, templates, isNew: true };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Level 3: baseline defaults
|
|
242
|
+
const cols = buildBaselineCols(entity, descriptor);
|
|
243
|
+
setTemplateCols(templates, entity, descriptor, cols);
|
|
244
|
+
await saveMetadataSchema(templates);
|
|
245
|
+
return { cols, templates, isNew: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Assemble metadata object from cols and file info.
|
|
250
|
+
*/
|
|
251
|
+
export function assembleMetadata(cols, filePath, entity, descriptor, appConfig) {
|
|
252
|
+
const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
253
|
+
const base = basename(filePath, extname(filePath));
|
|
254
|
+
const fileName = basename(filePath);
|
|
255
|
+
const isDocsFile = rel.startsWith('docs/');
|
|
256
|
+
const meta = { _entity: entity };
|
|
257
|
+
const contentColumns = [];
|
|
258
|
+
|
|
259
|
+
for (const col of cols) {
|
|
260
|
+
if (col.includes('=')) {
|
|
261
|
+
const eqIdx = col.indexOf('=');
|
|
262
|
+
const key = col.substring(0, eqIdx);
|
|
263
|
+
const val = col.substring(eqIdx + 1);
|
|
264
|
+
|
|
265
|
+
if (val === '@reference') {
|
|
266
|
+
const refPath = isDocsFile ? '@/' + rel : '@' + fileName;
|
|
267
|
+
meta[key] = refPath;
|
|
268
|
+
contentColumns.push(key);
|
|
269
|
+
} else {
|
|
270
|
+
meta[key] = val;
|
|
271
|
+
}
|
|
272
|
+
} else if (col === 'AppID') {
|
|
273
|
+
meta.AppID = appConfig?.AppID ?? '';
|
|
274
|
+
} else if (col === 'Name') {
|
|
275
|
+
meta.Name = base;
|
|
276
|
+
} else if (col === 'ShortName') {
|
|
277
|
+
meta.ShortName = toSnakeCase(base);
|
|
278
|
+
} else {
|
|
279
|
+
meta[col] = '';
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let refColMissing = false;
|
|
284
|
+
if (contentColumns.length === 0) {
|
|
285
|
+
refColMissing = true;
|
|
286
|
+
} else {
|
|
287
|
+
meta._companionReferenceColumns = contentColumns;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { meta, contentColumns, refColMissing };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Prompt user to select a reference column from available string cols.
|
|
295
|
+
*/
|
|
296
|
+
export async function promptReferenceColumn(cols, entity, descriptor) {
|
|
297
|
+
const stringCols = cols.filter(c => !c.includes('=') && c !== 'AppID' && c !== 'Name' && c !== 'ShortName');
|
|
298
|
+
if (stringCols.length === 0) return null;
|
|
299
|
+
|
|
300
|
+
const { selected } = await inquirer.prompt([{
|
|
301
|
+
type: 'list',
|
|
302
|
+
name: 'selected',
|
|
303
|
+
message: `Select the content/reference column for ${entity}${descriptor ? '/' + descriptor : ''}:`,
|
|
304
|
+
choices: stringCols,
|
|
305
|
+
}]);
|
|
306
|
+
|
|
307
|
+
return selected;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Build a template cols array from a clone record.
|
|
312
|
+
*/
|
|
313
|
+
export function buildTemplateFromCloneRecord(record, contentColsExtracted = []) {
|
|
314
|
+
const cols = [];
|
|
315
|
+
for (const key of Object.keys(record)) {
|
|
316
|
+
if (key.startsWith('_')) continue;
|
|
317
|
+
if (Array.isArray(record[key])) continue;
|
|
318
|
+
|
|
319
|
+
if (contentColsExtracted.includes(key)) {
|
|
320
|
+
cols.push(key + '=@reference');
|
|
321
|
+
} else if (key === 'Descriptor' && record[key]) {
|
|
322
|
+
cols.push('Descriptor=' + record[key]);
|
|
323
|
+
} else {
|
|
324
|
+
cols.push(key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return cols;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Parse a column entry with extended @reference syntax.
|
|
332
|
+
* Examples:
|
|
333
|
+
* "Content=@reference:@Name[@Extension]"
|
|
334
|
+
* "CustomSQL=@reference[sql]"
|
|
335
|
+
* "String20=@reference:SQLFileName[SQL]"
|
|
336
|
+
* "Column=@reference:@Name[@Ext||html]"
|
|
337
|
+
* "Column=@reference:@Name||fallback[html]"
|
|
338
|
+
* Returns: { column, filenameCol, filenameFallback, extensionCol, extensionFallback }
|
|
339
|
+
* - filenameCol: null | '@ColName' | 'LiteralSuffix'
|
|
340
|
+
* - extensionCol: null | '@ColName' | 'fixedExt'
|
|
341
|
+
* - *Fallback: fallback value after || within name/ext part
|
|
342
|
+
* Returns null if colExpr doesn't contain '=@reference'.
|
|
343
|
+
*/
|
|
344
|
+
export function parseReferenceExpression(colExpr) {
|
|
345
|
+
if (!colExpr || !colExpr.includes('=@reference')) return null;
|
|
346
|
+
const eqIdx = colExpr.indexOf('=@reference');
|
|
347
|
+
const column = colExpr.slice(0, eqIdx);
|
|
348
|
+
const rest = colExpr.slice(eqIdx + '=@reference'.length); // ':...' or '[...]' or ''
|
|
349
|
+
|
|
350
|
+
let filenameCol = null, filenameFallback = null;
|
|
351
|
+
let extensionCol = null, extensionFallback = null;
|
|
352
|
+
|
|
353
|
+
let extSource = rest;
|
|
354
|
+
|
|
355
|
+
if (rest.startsWith(':')) {
|
|
356
|
+
const afterColon = rest.slice(1);
|
|
357
|
+
const bracketIdx = afterColon.indexOf('[');
|
|
358
|
+
const namePart = bracketIdx === -1 ? afterColon : afterColon.slice(0, bracketIdx);
|
|
359
|
+
|
|
360
|
+
if (namePart.includes('||')) {
|
|
361
|
+
const [nameExpr, nameFall] = namePart.split('||');
|
|
362
|
+
filenameCol = nameExpr || null;
|
|
363
|
+
filenameFallback = nameFall || null;
|
|
364
|
+
} else {
|
|
365
|
+
filenameCol = namePart || null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
extSource = bracketIdx === -1 ? '' : afterColon.slice(bracketIdx);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Parse [extPart] from extSource, then {title} from remainder
|
|
372
|
+
let titleSource = extSource;
|
|
373
|
+
if (extSource.startsWith('[') && extSource.includes(']')) {
|
|
374
|
+
const closeBracket = extSource.lastIndexOf(']');
|
|
375
|
+
const extPart = extSource.slice(1, closeBracket);
|
|
376
|
+
if (extPart.includes('||')) {
|
|
377
|
+
const [extExpr, extFall] = extPart.split('||');
|
|
378
|
+
extensionCol = extExpr || null;
|
|
379
|
+
extensionFallback = extFall || null;
|
|
380
|
+
} else {
|
|
381
|
+
extensionCol = extPart || null;
|
|
382
|
+
}
|
|
383
|
+
titleSource = extSource.slice(closeBracket + 1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Parse {title} — human-readable alias used as the companion filename segment
|
|
387
|
+
let title = null;
|
|
388
|
+
if (titleSource.startsWith('{') && titleSource.includes('}')) {
|
|
389
|
+
title = titleSource.slice(1, titleSource.lastIndexOf('}')) || null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return { column, filenameCol, filenameFallback, extensionCol, extensionFallback, title };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Sanitize a string for use as a filename component.
|
|
397
|
+
* @param {string} s
|
|
398
|
+
* @returns {string}
|
|
399
|
+
*/
|
|
400
|
+
function sanitizeFilenameComponent(s) {
|
|
401
|
+
return s.replace(/[/\\:*?"<>|]/g, '_').trim();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Resolve the companion filename for a @reference expression.
|
|
406
|
+
* @param {object} parsed — result of parseReferenceExpression()
|
|
407
|
+
* @param {object} record — the server record (column values)
|
|
408
|
+
* @param {string} recordName — slugified record name (base for default naming)
|
|
409
|
+
* @returns {string|null} resolved filename (with extension) or null if extension unresolvable
|
|
410
|
+
*/
|
|
411
|
+
export function resolveCompanionFilename(parsed, record, recordName) {
|
|
412
|
+
const { column, filenameCol, filenameFallback, extensionCol, extensionFallback } = parsed;
|
|
413
|
+
|
|
414
|
+
// --- Resolve filename stem ---
|
|
415
|
+
let stem;
|
|
416
|
+
if (!filenameCol) {
|
|
417
|
+
// No :namePart → "<recordName>.<columnName>"
|
|
418
|
+
stem = `${recordName}.${column}`;
|
|
419
|
+
} else if (filenameCol.startsWith('@')) {
|
|
420
|
+
const val = record?.[filenameCol.slice(1)];
|
|
421
|
+
const resolved = val != null && String(val).trim() ? sanitizeFilenameComponent(String(val)) : null;
|
|
422
|
+
stem = resolved || filenameFallback || recordName;
|
|
423
|
+
} else {
|
|
424
|
+
// Static suffix → "<recordName>.<staticSuffix>"
|
|
425
|
+
stem = `${recordName}.${filenameCol}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- Resolve extension ---
|
|
429
|
+
if (!extensionCol) return stem; // bare @reference — no extension
|
|
430
|
+
|
|
431
|
+
let ext;
|
|
432
|
+
if (extensionCol.startsWith('@')) {
|
|
433
|
+
const val = record?.[extensionCol.slice(1)];
|
|
434
|
+
ext = val != null && String(val).trim() ? String(val).trim().toLowerCase() : extensionFallback;
|
|
435
|
+
} else {
|
|
436
|
+
ext = extensionCol.toLowerCase();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!ext) return null; // unresolvable — caller must prompt
|
|
440
|
+
return `${stem}.${ext}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Built-in @reference rules for well-known entities.
|
|
444
|
+
const BUILT_IN_REFERENCE_RULES = {
|
|
445
|
+
content: ['Content=@reference:@Name[@Extension]'],
|
|
446
|
+
output: ['CustomSQL=@reference[sql]'],
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Generate metadata_schema.json entries from schema.json.
|
|
451
|
+
* Preserves existing entries (never overwrites).
|
|
452
|
+
* Returns merged result.
|
|
453
|
+
* @param {object} schema — parsed schema.json
|
|
454
|
+
* @param {object} existing — existing metadata_schema.json contents
|
|
455
|
+
*/
|
|
456
|
+
export function generateMetadataFromSchema(schema, existing = {}) {
|
|
457
|
+
const entities = schema?.children?.entity ?? [];
|
|
458
|
+
const result = { ...existing };
|
|
459
|
+
|
|
460
|
+
for (const entity of entities) {
|
|
461
|
+
const entityKey = entity.UID;
|
|
462
|
+
if (!entityKey || result[entityKey] !== undefined) continue; // skip known/existing
|
|
463
|
+
|
|
464
|
+
const cols = (entity.children?.entity_column ?? [])
|
|
465
|
+
.filter(col => !col.PhysicalName.startsWith('_') && col.ReadOnly !== 1)
|
|
466
|
+
.map(col => {
|
|
467
|
+
if (col.DefaultValue === "b'0'") return `${col.PhysicalName}=0`;
|
|
468
|
+
if (col.DefaultValue === "b'1'") return `${col.PhysicalName}=1`;
|
|
469
|
+
return col.PhysicalName;
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Apply built-in @reference rules
|
|
473
|
+
const builtIn = BUILT_IN_REFERENCE_RULES[entityKey] ?? [];
|
|
474
|
+
const merged = [...cols];
|
|
475
|
+
for (const rule of builtIn) {
|
|
476
|
+
const parsed = parseReferenceExpression(rule);
|
|
477
|
+
if (!parsed) continue;
|
|
478
|
+
const idx = merged.indexOf(parsed.column);
|
|
479
|
+
if (idx !== -1) merged[idx] = rule; // replace plain column with @reference version
|
|
480
|
+
else merged.push(rule); // add if column not in list
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
result[entityKey] = merged;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Backward-compat re-exports
|
|
490
|
+
export const loadMetadataTemplates = loadMetadataSchema;
|
|
491
|
+
export const saveMetadataTemplates = saveMetadataSchema;
|
|
492
|
+
export const METADATA_TEMPLATES_FILE = METADATA_SCHEMA_FILE;
|
package/src/lib/save-to-disk.js
CHANGED
|
@@ -314,7 +314,7 @@ export async function saveToDisk(rows, columns, options = {}) {
|
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
if (contentColumnsList.length > 0) {
|
|
317
|
-
meta.
|
|
317
|
+
meta._companionReferenceColumns = contentColumnsList;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
// ── Save metadata ───────────────────────────────────────────────────
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { DboClient } from './client.js';
|
|
3
|
+
|
|
4
|
+
export const SCHEMA_FILE = 'schema.json';
|
|
5
|
+
const SCHEMA_API_PATH = '/api/app/object/_system';
|
|
6
|
+
|
|
7
|
+
/** Fetch schema from /api/app/object/_system. Returns parsed JSON or throws. */
|
|
8
|
+
export async function fetchSchema(options = {}) {
|
|
9
|
+
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
10
|
+
const result = await client.get(SCHEMA_API_PATH);
|
|
11
|
+
if (!result.ok || !result.data) throw new Error(`Schema fetch failed: HTTP ${result.status}`);
|
|
12
|
+
return result.data;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch only the _LastUpdated timestamp from the schema endpoint.
|
|
17
|
+
* Uses the column-filter syntax: /api/app/object/_system[_LastUpdated]
|
|
18
|
+
* Returns ISO date string or null on failure.
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchSchemaLastUpdated(options = {}) {
|
|
21
|
+
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
22
|
+
const result = await client.get(`${SCHEMA_API_PATH}[_LastUpdated]`);
|
|
23
|
+
if (!result.ok || !result.data) return null;
|
|
24
|
+
return result.data._LastUpdated || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check whether the server schema is newer than the local schema.json.
|
|
29
|
+
* Returns true if the server _LastUpdated is more recent (or local is missing).
|
|
30
|
+
*/
|
|
31
|
+
export async function isSchemaStale(options = {}) {
|
|
32
|
+
const local = await loadSchema();
|
|
33
|
+
if (!local || !local._LastUpdated) return true;
|
|
34
|
+
|
|
35
|
+
const serverTs = await fetchSchemaLastUpdated(options);
|
|
36
|
+
if (!serverTs) return false; // can't determine — assume not stale
|
|
37
|
+
|
|
38
|
+
return new Date(serverTs) > new Date(local._LastUpdated);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Load schema.json from project root. Returns parsed object or null if missing. */
|
|
42
|
+
export async function loadSchema() {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await readFile(SCHEMA_FILE, 'utf8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write schema.json to project root. */
|
|
51
|
+
export async function saveSchema(data) {
|
|
52
|
+
await writeFile(SCHEMA_FILE, JSON.stringify(data, null, 2) + '\n');
|
|
53
|
+
}
|
package/src/lib/structure.js
CHANGED
|
@@ -326,7 +326,7 @@ export function findChildBins(binId, structure) {
|
|
|
326
326
|
/** Root for all extension descriptor-grouped sub-directories */
|
|
327
327
|
export const EXTENSION_DESCRIPTORS_DIR = 'lib/extension';
|
|
328
328
|
|
|
329
|
-
/**
|
|
329
|
+
/** @deprecated Used only by migration 011 to relocate existing files. */
|
|
330
330
|
export const EXTENSION_UNSUPPORTED_DIR = 'lib/extension/_unsupported';
|
|
331
331
|
|
|
332
332
|
/** Root-level documentation directory (remains at project root) */
|
|
@@ -380,7 +380,7 @@ export function buildDescriptorMapping(extensionRecords) {
|
|
|
380
380
|
* Resolve a field value that may be a base64-encoded object from the server API.
|
|
381
381
|
* Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
|
|
382
382
|
*/
|
|
383
|
-
function resolveFieldValue(value) {
|
|
383
|
+
export function resolveFieldValue(value) {
|
|
384
384
|
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
385
385
|
&& value.encoding === 'base64' && typeof value.value === 'string') {
|
|
386
386
|
return Buffer.from(value.value, 'base64').toString('utf8');
|
|
@@ -430,7 +430,7 @@ export async function loadDescriptorMapping() {
|
|
|
430
430
|
export function resolveExtensionSubDir(record, mapping) {
|
|
431
431
|
const descriptor = record.Descriptor;
|
|
432
432
|
if (!descriptor || !mapping[descriptor]) {
|
|
433
|
-
return
|
|
433
|
+
return EXTENSION_DESCRIPTORS_DIR;
|
|
434
434
|
}
|
|
435
435
|
return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
|
|
436
436
|
}
|
package/src/lib/tagging.js
CHANGED
|
@@ -117,7 +117,7 @@ async function _getCompanionPaths(metaPath) {
|
|
|
117
117
|
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
118
118
|
const dir = dirname(metaPath);
|
|
119
119
|
const paths = [];
|
|
120
|
-
for (const col of (meta._contentColumns || [])) {
|
|
120
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
121
121
|
const ref = meta[col];
|
|
122
122
|
if (ref && String(ref).startsWith('@')) {
|
|
123
123
|
const candidate = join(dir, String(ref).substring(1));
|
package/src/lib/ticketing.js
CHANGED
|
@@ -13,7 +13,7 @@ function ticketingPath() {
|
|
|
13
13
|
return join(dboDir(), TICKETING_FILE);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, records: [] };
|
|
16
|
+
const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, ticket_confirmed: false, records: [] };
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Load ticketing.local.json. Returns default structure if missing or corrupted.
|
|
@@ -31,6 +31,7 @@ export async function loadTicketing() {
|
|
|
31
31
|
return {
|
|
32
32
|
ticket_id: data.ticket_id || null,
|
|
33
33
|
ticketing_required: !!data.ticketing_required,
|
|
34
|
+
ticket_confirmed: !!data.ticket_confirmed,
|
|
34
35
|
records: Array.isArray(data.records) ? data.records : [],
|
|
35
36
|
};
|
|
36
37
|
} catch (err) {
|
|
@@ -74,6 +75,7 @@ export async function getRecordTicket(uid) {
|
|
|
74
75
|
export async function setGlobalTicket(ticketId) {
|
|
75
76
|
const data = await loadTicketing();
|
|
76
77
|
data.ticket_id = ticketId;
|
|
78
|
+
data.ticket_confirmed = false; // reset confirmation when ticket changes
|
|
77
79
|
await saveTicketing(data);
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -123,6 +125,7 @@ export async function isTicketingRequired() {
|
|
|
123
125
|
export async function clearGlobalTicket() {
|
|
124
126
|
const data = await loadTicketing();
|
|
125
127
|
data.ticket_id = null;
|
|
128
|
+
data.ticket_confirmed = false;
|
|
126
129
|
await saveTicketing(data);
|
|
127
130
|
}
|
|
128
131
|
|
|
@@ -231,6 +234,12 @@ export async function checkStoredTicket(options, context = '') {
|
|
|
231
234
|
return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };
|
|
232
235
|
}
|
|
233
236
|
|
|
237
|
+
// Stored ticket exists and confirmed — auto-use without prompting
|
|
238
|
+
if (data.ticket_confirmed) {
|
|
239
|
+
log.info(`Using confirmed ticket "${data.ticket_id}"`);
|
|
240
|
+
return { useTicket: true, clearTicket: false, cancel: false };
|
|
241
|
+
}
|
|
242
|
+
|
|
234
243
|
// Stored ticket exists — prompt to use, change, or clear
|
|
235
244
|
const { action } = await inquirer.prompt([{
|
|
236
245
|
type: 'list',
|
|
@@ -238,6 +247,7 @@ export async function checkStoredTicket(options, context = '') {
|
|
|
238
247
|
message: `Use stored Ticket ID "${data.ticket_id}" for this submission?${suffix}`,
|
|
239
248
|
choices: [
|
|
240
249
|
{ name: `Yes, use "${data.ticket_id}"`, value: 'use' },
|
|
250
|
+
{ name: `Yes, use "${data.ticket_id}" for all future submissions (don't ask again)`, value: 'use_confirmed' },
|
|
241
251
|
{ name: 'Use a different ticket for this submission only', value: 'alt_once' },
|
|
242
252
|
{ name: 'Use a different ticket for this and future submissions', value: 'alt_save' },
|
|
243
253
|
{ name: 'No, clear stored ticket', value: 'clear' },
|
|
@@ -245,6 +255,12 @@ export async function checkStoredTicket(options, context = '') {
|
|
|
245
255
|
],
|
|
246
256
|
}]);
|
|
247
257
|
|
|
258
|
+
if (action === 'use_confirmed') {
|
|
259
|
+
await saveTicketing({ ...data, ticket_confirmed: true });
|
|
260
|
+
log.dim(` Ticket "${data.ticket_id}" confirmed — will auto-apply without prompting`);
|
|
261
|
+
return { useTicket: true, clearTicket: false, cancel: false };
|
|
262
|
+
}
|
|
263
|
+
|
|
248
264
|
if (action === 'alt_once' || action === 'alt_save') {
|
|
249
265
|
const { altTicket } = await inquirer.prompt([{
|
|
250
266
|
type: 'input',
|
|
@@ -257,7 +273,7 @@ export async function checkStoredTicket(options, context = '') {
|
|
|
257
273
|
return { useTicket: false, clearTicket: false, cancel: true };
|
|
258
274
|
}
|
|
259
275
|
if (action === 'alt_save') {
|
|
260
|
-
await saveTicketing({ ...data, ticket_id: ticket });
|
|
276
|
+
await saveTicketing({ ...data, ticket_id: ticket, ticket_confirmed: false });
|
|
261
277
|
log.dim(` Stored ticket updated to "${ticket}"`);
|
|
262
278
|
}
|
|
263
279
|
return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };
|