@dboio/cli 0.15.3 → 0.17.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 +165 -76
- package/bin/dbo.js +2 -2
- package/package.json +1 -1
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +165 -76
- package/src/commands/adopt.js +534 -0
- package/src/commands/clone.js +365 -143
- 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 +13 -9
- 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 +3 -2
- package/src/lib/dependencies.js +217 -2
- package/src/lib/diff.js +9 -11
- package/src/lib/filenames.js +3 -3
- package/src/lib/ignore.js +1 -0
- package/src/{commands/add.js → lib/insert.js} +133 -478
- 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/toe-stepping.js +2 -2
- package/src/migrations/007-natural-entity-companion-filenames.js +5 -2
- package/src/migrations/011-schema-driven-metadata.js +120 -0
package/src/lib/logger.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
|
|
3
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
|
+
|
|
3
5
|
export const log = {
|
|
4
6
|
info(msg) { console.log(chalk.blue('ℹ'), msg); },
|
|
5
7
|
success(msg) { console.log(chalk.green('✓'), msg); },
|
|
@@ -9,4 +11,37 @@ export const log = {
|
|
|
9
11
|
plain(msg) { console.log(msg); },
|
|
10
12
|
verbose(msg) { console.log(chalk.dim(' →'), chalk.dim(msg)); },
|
|
11
13
|
label(label, value) { console.log(chalk.dim(` ${label}:`), value); },
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start a spinner with a message. Returns { stop(finalMsg) }.
|
|
17
|
+
* The spinner writes to stderr so it doesn't pollute piped output.
|
|
18
|
+
*/
|
|
19
|
+
spinner(msg) {
|
|
20
|
+
let i = 0;
|
|
21
|
+
const stream = process.stderr;
|
|
22
|
+
const isTTY = stream.isTTY;
|
|
23
|
+
if (!isTTY) {
|
|
24
|
+
// Non-TTY: just print the message once, return a no-op stop
|
|
25
|
+
console.log(chalk.dim(msg));
|
|
26
|
+
return { update() {}, stop(final) { if (final) console.log(final); } };
|
|
27
|
+
}
|
|
28
|
+
const render = () => {
|
|
29
|
+
const frame = chalk.cyan(SPINNER_FRAMES[i % SPINNER_FRAMES.length]);
|
|
30
|
+
stream.clearLine(0);
|
|
31
|
+
stream.cursorTo(0);
|
|
32
|
+
stream.write(`${frame} ${chalk.dim(msg)}`);
|
|
33
|
+
i++;
|
|
34
|
+
};
|
|
35
|
+
render();
|
|
36
|
+
const timer = setInterval(render, 80);
|
|
37
|
+
return {
|
|
38
|
+
update(newMsg) { msg = newMsg; },
|
|
39
|
+
stop(final) {
|
|
40
|
+
clearInterval(timer);
|
|
41
|
+
stream.clearLine(0);
|
|
42
|
+
stream.cursorTo(0);
|
|
43
|
+
if (final) console.log(final);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
12
47
|
};
|
|
@@ -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/toe-stepping.js
CHANGED
|
@@ -443,7 +443,7 @@ export async function checkToeStepping(records, client, baseline, options, appSh
|
|
|
443
443
|
*/
|
|
444
444
|
async function showPushDiff(serverEntry, localMeta, metaPath) {
|
|
445
445
|
const metaDir = dirname(metaPath);
|
|
446
|
-
const contentCols = localMeta._contentColumns || [];
|
|
446
|
+
const contentCols = localMeta._companionReferenceColumns || localMeta._contentColumns || [];
|
|
447
447
|
|
|
448
448
|
// Compare content file columns
|
|
449
449
|
for (const col of contentCols) {
|
|
@@ -475,7 +475,7 @@ async function showPushDiff(serverEntry, localMeta, metaPath) {
|
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
// Compare metadata fields
|
|
478
|
-
const skipFields = new Set(['_entity', '_contentColumns', '_mediaFile', 'children', '_pathConfirmed']);
|
|
478
|
+
const skipFields = new Set(['_entity', '_contentColumns', '_companionReferenceColumns', '_mediaFile', 'children', '_pathConfirmed']);
|
|
479
479
|
for (const col of Object.keys(serverEntry)) {
|
|
480
480
|
if (skipFields.has(col)) continue;
|
|
481
481
|
if (contentCols.includes(col)) continue;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, readFile, writeFile, rename, access, mkdir } from 'fs/promises';
|
|
1
|
+
import { readdir, readFile, writeFile, rename, access, mkdir, stat, utimes } from 'fs/promises';
|
|
2
2
|
import { join, basename, dirname } from 'path';
|
|
3
3
|
import { log } from '../lib/logger.js';
|
|
4
4
|
import { stripUidFromFilename, hasUidInFilename, isMetadataFile } from '../lib/filenames.js';
|
|
@@ -102,10 +102,13 @@ export default async function run(_options) {
|
|
|
102
102
|
claimedNaturals.add(naturalPath);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
// Rewrite metadata if @references were updated
|
|
105
|
+
// Rewrite metadata if @references were updated (preserve timestamps
|
|
106
|
+
// so the write doesn't cause false "local changes" during clone/pull)
|
|
106
107
|
if (metaChanged) {
|
|
107
108
|
try {
|
|
109
|
+
const before = await stat(metaPath);
|
|
108
110
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
111
|
+
await utimes(metaPath, before.atime, before.mtime);
|
|
109
112
|
totalRefsUpdated++;
|
|
110
113
|
} catch { /* non-critical */ }
|
|
111
114
|
}
|