@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/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;
@@ -314,7 +314,7 @@ export async function saveToDisk(rows, columns, options = {}) {
314
314
  }
315
315
 
316
316
  if (contentColumnsList.length > 0) {
317
- meta._contentColumns = contentColumnsList;
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
+ }
@@ -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
- /** Extensions that cannot be mapped go here */
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 EXTENSION_UNSUPPORTED_DIR;
433
+ return EXTENSION_DESCRIPTORS_DIR;
434
434
  }
435
435
  return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
436
436
  }
@@ -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));
@@ -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
  }