@dboio/cli 0.11.4 → 0.13.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.
Files changed (48) hide show
  1. package/README.md +126 -3
  2. package/bin/dbo.js +4 -0
  3. package/package.json +1 -1
  4. package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
  5. package/plugins/claude/dbo/commands/dbo.md +65 -244
  6. package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
  7. package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
  8. package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
  9. package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
  10. package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
  11. package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
  12. package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
  13. package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
  14. package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
  15. package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
  16. package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
  17. package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
  18. package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
  19. package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
  20. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
  21. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
  22. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
  23. package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
  24. package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
  25. package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
  26. package/plugins/claude/dbo/docs/dbo-cli-readme.md +2222 -0
  27. package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
  28. package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
  29. package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
  30. package/plugins/claude/dbo/skills/cli/SKILL.md +62 -246
  31. package/src/commands/add.js +366 -62
  32. package/src/commands/build.js +102 -0
  33. package/src/commands/clone.js +602 -139
  34. package/src/commands/diff.js +4 -0
  35. package/src/commands/init.js +16 -2
  36. package/src/commands/input.js +3 -1
  37. package/src/commands/mv.js +12 -4
  38. package/src/commands/push.js +265 -70
  39. package/src/commands/rm.js +16 -3
  40. package/src/commands/run.js +81 -0
  41. package/src/lib/config.js +39 -0
  42. package/src/lib/delta.js +7 -1
  43. package/src/lib/diff.js +24 -2
  44. package/src/lib/filenames.js +120 -41
  45. package/src/lib/ignore.js +6 -0
  46. package/src/lib/input-parser.js +13 -4
  47. package/src/lib/scripts.js +232 -0
  48. package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
@@ -1,21 +1,59 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, readdir, stat, writeFile, mkdir, rename } from 'fs/promises';
2
+ import { readFile, readdir, stat, writeFile, mkdir, unlink } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
- import { loadAppConfig, loadAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from '../lib/config.js';
9
+ import { loadAppConfig, loadAppJsonBaseline, saveAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from '../lib/config.js';
10
10
  import { resolveDirective, resolveTemplateCols, assembleMetadata, promptReferenceColumn, setTemplateCols, saveMetadataTemplates, loadMetadataTemplates } from '../lib/metadata-templates.js';
11
11
  import { buildUidFilename, hasUidInFilename } from '../lib/filenames.js';
12
12
  import { setFileTimestamps } from '../lib/timestamps.js';
13
13
  import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
14
14
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
15
15
  import { loadIgnore } from '../lib/ignore.js';
16
- import { loadStructureFile, findBinByPath } from '../lib/structure.js';
16
+ import { loadStructureFile, findBinByPath, BINS_DIR } from '../lib/structure.js';
17
17
  import { runPendingMigrations } from '../lib/migrations.js';
18
18
 
19
+ // ─── File type classification for auto-detection in bin directories ────────
20
+
21
+ /** Extensions treated as content entities (text-based, stored in content.Content) */
22
+ const CONTENT_EXTENSIONS = new Set([
23
+ 'sh', 'js', 'json', 'html', 'txt', 'xml', 'css', 'md',
24
+ 'htm', 'svg', 'sql', 'csv', 'yaml', 'yml', 'ts', 'jsx', 'tsx',
25
+ ]);
26
+
27
+ /** Extensions treated as media entities (binary files: images, audio, video) */
28
+ const MEDIA_EXTENSIONS = new Set([
29
+ // Images
30
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif',
31
+ // Audio
32
+ 'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
33
+ // Video
34
+ 'mp4', 'webm', 'mov', 'avi', 'mkv', 'wmv', 'flv',
35
+ // Fonts
36
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
37
+ // Documents / archives (binary)
38
+ 'pdf', 'zip', 'gz', 'tar',
39
+ ]);
40
+
41
+ /** Simple MIME type lookup for common media extensions */
42
+ const MIME_TYPES = {
43
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
44
+ webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', svg: 'image/svg+xml',
45
+ tiff: 'image/tiff', tif: 'image/tiff', avif: 'image/avif',
46
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', aac: 'audio/aac',
47
+ flac: 'audio/flac', m4a: 'audio/mp4', wma: 'audio/x-ms-wma',
48
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
49
+ avi: 'video/x-msvideo', mkv: 'video/x-matroska', wmv: 'video/x-ms-wmv',
50
+ flv: 'video/x-flv',
51
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
52
+ eot: 'application/vnd.ms-fontobject',
53
+ pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
54
+ tar: 'application/x-tar',
55
+ };
56
+
19
57
  export const addCommand = new Command('add')
20
58
  .description('Add a new file to DBO.io (creates record on server)')
21
59
  .argument('<path>', 'File or "." to scan current directory')
@@ -118,6 +156,165 @@ async function detectManifestFile(filePath) {
118
156
  return { meta, metaPath };
119
157
  }
120
158
 
159
+ /**
160
+ * Detect whether a file lives inside lib/bins/ and auto-classify as content or media.
161
+ *
162
+ * Content extensions: sh, js, json, html, txt, xml, css, md, etc.
163
+ * Media extensions: png, jpg, webp, mp4, woff2, pdf, etc.
164
+ *
165
+ * Returns { meta, metaPath } or null if not applicable.
166
+ */
167
+ export async function detectBinFile(filePath) {
168
+ const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
169
+
170
+ // Must be inside lib/bins/ (or legacy Bins/)
171
+ if (!rel.startsWith(BINS_DIR + '/') && !rel.toLowerCase().startsWith('bins/')) return null;
172
+
173
+ const ext = extname(filePath).replace('.', '').toLowerCase();
174
+ if (!ext) return null;
175
+
176
+ const isContent = CONTENT_EXTENSIONS.has(ext);
177
+ const isMedia = MEDIA_EXTENSIONS.has(ext);
178
+ if (!isContent && !isMedia) return null;
179
+
180
+ const appConfig = await loadAppConfig();
181
+ const structure = await loadStructureFile();
182
+
183
+ // Resolve BinID from the file's parent directory
184
+ const fileDir = dirname(rel);
185
+ const bin = findBinByPath(fileDir, structure);
186
+
187
+ const fileName = basename(filePath);
188
+ const base = basename(filePath, extname(filePath));
189
+
190
+ // Build metadata path next to the file
191
+ const metaPath = join(dirname(filePath), `${base}.metadata.json`);
192
+
193
+ // Use the bin's server-side Path field (e.g. "assets/css", "assets/gfx")
194
+ const binPath = bin?.path || '';
195
+
196
+ if (isContent) {
197
+ // content.Name = filename without extension, content.Path = <binPath>/<Name>.<Extension>
198
+ const contentPath = binPath
199
+ ? `${binPath}/${base}.${ext.toLowerCase()}`
200
+ : `${base}.${ext.toLowerCase()}`;
201
+
202
+ const meta = {
203
+ _entity: 'content',
204
+ _contentColumns: ['Content'],
205
+ Name: base,
206
+ Content: `@${fileName}`,
207
+ Extension: ext.toUpperCase(),
208
+ Path: contentPath,
209
+ Active: 1,
210
+ Public: 0,
211
+ };
212
+ if (bin) meta.BinID = bin.binId;
213
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
214
+
215
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
216
+ log.success(`Auto-created content metadata for "${fileName}"`);
217
+
218
+ // Seed metadata_templates.json with content fields if missing
219
+ await seedMetadataTemplate();
220
+
221
+ return { meta, metaPath };
222
+ }
223
+
224
+ // Media: media.Name = filename without extension, media.Filename = filename with extension
225
+ // media.Path = bin directory path, media.FullPath = /media/<AppShortName>/app/<binPath>/<Filename>
226
+ const meta = {
227
+ _entity: 'media',
228
+ _mediaFile: `@${fileName}`,
229
+ Name: base,
230
+ Filename: fileName,
231
+ Extension: ext,
232
+ Ownership: 'App',
233
+ };
234
+ if (bin) meta.BinID = bin.binId;
235
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
236
+ if (binPath) meta.Path = binPath;
237
+
238
+ const mimeType = MIME_TYPES[ext];
239
+ if (mimeType) meta.MimeType = mimeType;
240
+
241
+ // Build FullPath: /media/<AppShortName>/app/<binPath>/<Filename>
242
+ if (appConfig.AppShortName) {
243
+ const fullPathParts = ['', 'media', appConfig.AppShortName, 'app'];
244
+ if (binPath) fullPathParts.push(binPath);
245
+ fullPathParts.push(fileName);
246
+ meta.FullPath = fullPathParts.join('/');
247
+ }
248
+
249
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
250
+ log.success(`Auto-created media metadata for "${fileName}"`);
251
+
252
+ // Seed metadata_templates.json with media fields if missing
253
+ await seedMetadataTemplate();
254
+
255
+ return { meta, metaPath };
256
+ }
257
+
258
+ /** Default template cols for core entity types */
259
+ const TEMPLATE_DEFAULTS = {
260
+ content: [
261
+ 'AppID', 'BinID', 'Name', 'Path', 'Content=@reference', 'Extension',
262
+ 'Type', 'Title', 'Active', 'Public=0', 'SiteID', 'Description',
263
+ 'Keywords', 'Parameters', 'CacheControl', 'RequireSSL', 'EntityID',
264
+ ],
265
+ media: [
266
+ 'AppID', 'BinID', 'Name', 'Path', 'Filename', 'Extension',
267
+ 'MimeType', 'FileSize', 'Ownership', 'Public', 'Description',
268
+ 'Duration', 'ParentMediaID',
269
+ ],
270
+ output: [
271
+ 'AppID', 'BinID', 'Name', 'Type', 'CustomSQL=@reference',
272
+ 'Active', 'Public', 'Title', 'Description', 'Parameters',
273
+ 'Cache', 'Limit', 'MaxRows', 'RowsPerPage', 'ApplyLocks',
274
+ 'AdhocFilters', 'BaseEntityID', 'DataSourceID',
275
+ 'DefaultMediaID', 'TemplateContentID', 'children',
276
+ ],
277
+ output_value: [
278
+ 'OutputID', 'Title', 'ShortName', 'OutputType', 'OrderNumber',
279
+ 'CustomSQL', 'Format', 'Aggregate', 'DatePart', 'Distinct',
280
+ 'Hide', 'Shared', 'Summary', 'SortType', 'SortOrder',
281
+ 'EnforceSecurity', 'ReferencedEntityColumnID',
282
+ 'OutputValueEntityColumnRelID', 'children',
283
+ ],
284
+ output_value_filter: [
285
+ 'OutputID', 'OutputValueID', 'ShortName', 'FilterType', 'Operator',
286
+ 'OrderNumber', 'CustomSQL', 'Prompted', 'Required', 'Inclusive',
287
+ 'PrimaryValue', 'SecondaryValue', 'FilterPreset',
288
+ 'ApplyToAggregate', 'ValuesByOutputID', 'children',
289
+ ],
290
+ output_value_entity_column_rel: [
291
+ 'OutputID', 'OutputValueID', 'JoinType', 'OrderNumber', 'CustomSQL',
292
+ 'Alias', 'SourceJoinID', 'SourceEntityColumnID',
293
+ 'ReferencedEntityColumnID', 'children',
294
+ ],
295
+ };
296
+
297
+ /**
298
+ * Seed .dbo/metadata_templates.json with default fields for the given entity
299
+ * (and any other missing core entities) if an entry doesn't already exist.
300
+ */
301
+ async function seedMetadataTemplate() {
302
+ const templates = await loadMetadataTemplates() ?? {};
303
+
304
+ // Seed all missing core entities in one pass
305
+ const seeded = [];
306
+ for (const [name, cols] of Object.entries(TEMPLATE_DEFAULTS)) {
307
+ if (templates[name]) continue;
308
+ templates[name] = cols;
309
+ seeded.push(name);
310
+ }
311
+
312
+ if (seeded.length === 0) return;
313
+
314
+ await saveMetadataTemplates(templates);
315
+ log.dim(` Seeded metadata_templates.json with ${seeded.join(', ')} fields`);
316
+ }
317
+
121
318
  async function addSingleFile(filePath, client, options, batchDefaults) {
122
319
  // Check .dboignore before doing any processing
123
320
  const ig = await loadIgnore();
@@ -235,6 +432,12 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
235
432
  return await submitAdd(manifestMeta.meta, manifestMeta.metaPath, filePath, client, options);
236
433
  }
237
434
 
435
+ // Step 3e: Auto-detect content/media files in bin directories (lib/bins/)
436
+ const binMeta = await detectBinFile(filePath);
437
+ if (binMeta) {
438
+ return await submitAdd(binMeta.meta, binMeta.metaPath, filePath, client, options);
439
+ }
440
+
238
441
  // Step 4: No usable metadata — interactive wizard
239
442
  const inquirer = (await import('inquirer')).default;
240
443
 
@@ -363,7 +566,7 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
363
566
  /**
364
567
  * Submit an insert to the server from a metadata object.
365
568
  */
366
- async function submitAdd(meta, metaPath, filePath, client, options) {
569
+ export async function submitAdd(meta, metaPath, filePath, client, options) {
367
570
  const entity = meta._entity;
368
571
  const contentCols = new Set(meta._contentColumns || []);
369
572
  const metaDir = dirname(metaPath);
@@ -442,11 +645,22 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
442
645
  throw new Error('Add failed');
443
646
  }
444
647
 
445
- // Extract UID from response and rename files to ~uid convention
648
+ // Extract UID and server-populated fields from response, merge into metadata
446
649
  const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
447
650
  if (addResults.length > 0) {
448
- const returnedUID = addResults[0].UID;
449
- const returnedLastUpdated = addResults[0]._LastUpdated;
651
+ const serverRecord = addResults[0];
652
+ const returnedUID = serverRecord.UID;
653
+ const returnedLastUpdated = serverRecord._LastUpdated;
654
+
655
+ // Merge server-populated fields into meta (e.g. _id, ContentID, _LastUpdated, _CreatedOn)
656
+ for (const [key, value] of Object.entries(serverRecord)) {
657
+ // Skip internal/transient fields and arrays (Physical, Errors)
658
+ if (Array.isArray(value)) continue;
659
+ if (key === 'entity' || key === 'RowID') continue;
660
+ // Don't overwrite existing content/media references or private fields we manage
661
+ if (meta[key] !== undefined && !key.startsWith('_')) continue;
662
+ meta[key] = value;
663
+ }
450
664
 
451
665
  if (returnedUID) {
452
666
  // Check if UID is already embedded in the filename (idempotency guard)
@@ -456,54 +670,39 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
456
670
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
457
671
  log.success(`UID already in filename: ${returnedUID}`);
458
672
  } else {
459
- // Compute new names
673
+ // Companion file NOT renamed — only update UID in metadata and rename metadata file
460
674
  const metaDir = dirname(metaPath);
461
- const currentExt = extname(filePath).substring(1);
462
- const currentBase = basename(filePath, extname(filePath));
463
- const newBase = buildUidFilename(currentBase, returnedUID);
464
- const newFilename = currentExt ? `${newBase}.${currentExt}` : newBase;
465
- const newFilePath = join(metaDir, newFilename);
466
- const newMetaPath = join(metaDir, `${newBase}.metadata.json`);
467
-
468
- // Rename content file
469
- try {
470
- await rename(filePath, newFilePath);
471
- log.success(`Renamed: ${basename(filePath)} → ${newFilename}`);
472
- } catch (err) {
473
- log.warn(` Could not rename content file: ${err.message}`);
474
- }
675
+ const metaBase = basename(metaPath, '.metadata.json');
676
+ const newMetaBase = buildUidFilename(metaBase, returnedUID);
677
+ const newMetaPath = join(metaDir, `${newMetaBase}.metadata.json`);
475
678
 
476
- // Update @reference in metadata
477
679
  meta.UID = returnedUID;
478
- for (const col of (meta._contentColumns || [])) {
479
- const ref = meta[col];
480
- if (ref && String(ref).startsWith('@')) {
481
- const oldRefFile = String(ref).substring(1);
482
- if (oldRefFile === basename(filePath)) {
483
- meta[col] = `@${newFilename}`;
484
- }
485
- }
486
- }
487
680
 
488
- // Write and rename metadata file
489
681
  await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
490
682
  if (metaPath !== newMetaPath) {
491
- try { await rename(metaPath, newMetaPath); } catch { /* already written */ }
683
+ try { await unlink(metaPath); } catch { /* old file already gone */ }
492
684
  }
493
685
  log.dim(` Renamed metadata: ${basename(metaPath)} → ${basename(newMetaPath)}`);
686
+ log.success(`UID assigned: ${returnedUID}`);
494
687
 
495
- // Restore timestamps from server _LastUpdated
688
+ // Restore timestamps for metadata and companion files
496
689
  const config = await loadConfig();
497
690
  const serverTz = config.ServerTimezone;
498
691
  if (serverTz && returnedLastUpdated) {
499
692
  try {
500
- await setFileTimestamps(newFilePath, returnedLastUpdated, returnedLastUpdated, serverTz);
501
693
  await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
694
+ for (const col of (meta._contentColumns || [])) {
695
+ const ref = meta[col];
696
+ if (ref && String(ref).startsWith('@')) {
697
+ const fp = join(metaDir, String(ref).substring(1));
698
+ try { await setFileTimestamps(fp, returnedLastUpdated, returnedLastUpdated, serverTz); } catch {}
699
+ }
700
+ }
502
701
  } catch { /* non-critical */ }
503
702
  }
504
703
 
505
- log.success(`UID assigned: ${returnedUID}`);
506
- log.dim(` Files renamed to ~${returnedUID} convention`);
704
+ // Update .app_baseline.json so subsequent pull/diff recognize this record
705
+ await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
507
706
  }
508
707
  log.dim(` Run "dbo pull -e ${entity} ${returnedUID}" to populate all columns`);
509
708
  }
@@ -512,6 +711,30 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
512
711
  return { entity: meta._entity };
513
712
  }
514
713
 
714
+ async function updateBaselineWithNewRecord(meta, uid, lastUpdated) {
715
+ try {
716
+ const baseline = await loadAppJsonBaseline();
717
+ if (!baseline) return;
718
+ const entity = meta._entity;
719
+ if (!entity) return;
720
+ if (!baseline.children) baseline.children = {};
721
+ if (!baseline.children[entity]) baseline.children[entity] = [];
722
+
723
+ const arr = baseline.children[entity];
724
+ const existingIdx = arr.findIndex(r => r.UID === uid || (meta._id && r._id === meta._id));
725
+ const entry = { ...meta, UID: uid };
726
+ if (lastUpdated) entry._LastUpdated = lastUpdated;
727
+
728
+ if (existingIdx >= 0) {
729
+ arr[existingIdx] = { ...arr[existingIdx], ...entry };
730
+ } else {
731
+ arr.push(entry);
732
+ }
733
+
734
+ await saveAppJsonBaseline(baseline);
735
+ } catch { /* baseline update is non-critical */ }
736
+ }
737
+
515
738
  /**
516
739
  * Scan a directory for un-added files and add them.
517
740
  */
@@ -579,54 +802,135 @@ async function addDirectory(dirPath, client, options) {
579
802
 
580
803
  /**
581
804
  * Recursively find files that are not yet added to the server.
582
- * A file is "un-added" if:
583
- * - It has no companion .metadata.json, OR
584
- * - Its .metadata.json exists but has no _CreatedOn (never been on server)
805
+ * A file is "un-added" if no *.metadata.json references it via @.
806
+ *
807
+ * Detection uses two layers:
808
+ * 1. Per-directory: scan sibling *.metadata.json files (handles ~UID naming)
809
+ * 2. Project-wide: buildReferencedFileSet catches cross-directory @/ references
585
810
  */
586
- async function findUnaddedFiles(dir, ig) {
811
+ export async function findUnaddedFiles(dir, ig, referencedFiles) {
587
812
  if (!ig) ig = await loadIgnore();
588
813
 
814
+ // On first call, build the set of files referenced by existing metadata
815
+ if (!referencedFiles) {
816
+ referencedFiles = await buildReferencedFileSet();
817
+ }
818
+
589
819
  const results = [];
590
820
  const entries = await readdir(dir, { withFileTypes: true });
591
821
 
822
+ // Build a set of filenames referenced by sibling metadata in this directory.
823
+ // This handles the ~UID naming convention: colors~uid.metadata.json → @colors.css
824
+ const localRefs = new Set();
825
+ for (const entry of entries) {
826
+ if (!entry.name.endsWith('.metadata.json')) continue;
827
+ try {
828
+ const raw = await readFile(join(dir, entry.name), 'utf8');
829
+ if (!raw.trim()) continue;
830
+ const meta = JSON.parse(raw);
831
+ // Only count records that are on the server (have UID or _CreatedOn)
832
+ if (!meta.UID && !meta._CreatedOn) continue;
833
+ for (const col of (meta._contentColumns || [])) {
834
+ const val = meta[col];
835
+ if (typeof val === 'string' && val.startsWith('@') && !val.startsWith('@/')) {
836
+ localRefs.add(val.substring(1));
837
+ }
838
+ }
839
+ if (meta._mediaFile && typeof meta._mediaFile === 'string' && meta._mediaFile.startsWith('@')) {
840
+ localRefs.add(meta._mediaFile.substring(1));
841
+ }
842
+ } catch { /* skip unreadable */ }
843
+ }
844
+
592
845
  for (const entry of entries) {
593
846
  const fullPath = join(dir, entry.name);
594
847
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
595
848
 
596
849
  if (entry.isDirectory()) {
597
850
  if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
598
- results.push(...await findUnaddedFiles(fullPath, ig));
851
+ results.push(...await findUnaddedFiles(fullPath, ig, referencedFiles));
599
852
  continue;
600
853
  }
601
854
 
602
- // Skip metadata files themselves (structural, not user-configurable)
855
+ // Skip metadata files themselves
603
856
  if (entry.name.endsWith('.metadata.json')) continue;
604
857
  // Skip ignored files
605
858
  if (ig.ignores(relPath)) continue;
606
859
 
607
- // Check for companion metadata
608
- const ext = extname(entry.name);
609
- const base = basename(entry.name, ext);
610
- const metaPath = join(dir, `${base}.metadata.json`);
860
+ // Check 1: sibling metadata references this file via @
861
+ if (localRefs.has(entry.name)) continue;
862
+
863
+ // Check 2: project-wide cross-directory @/ references
864
+ if (referencedFiles.has(relPath)) continue;
611
865
 
612
- let hasMetadata = false;
613
- let isOnServer = false;
866
+ results.push(fullPath);
867
+ }
868
+
869
+ return results;
870
+ }
871
+
872
+ /**
873
+ * Scan all metadata files and build a set of content files they reference.
874
+ * This catches files like docs/Chat.md whose metadata lives elsewhere
875
+ * (e.g. lib/extension/Documentation/Chat~uid.metadata.json with "@/docs/Chat.md").
876
+ */
877
+ async function buildReferencedFileSet() {
878
+ const referenced = new Set();
879
+ await _scanMetadataRefs(process.cwd(), referenced);
880
+ return referenced;
881
+ }
882
+
883
+ async function _scanMetadataRefs(dir, referenced) {
884
+ const ig = await loadIgnore();
885
+ let entries;
886
+ try {
887
+ entries = await readdir(dir, { withFileTypes: true });
888
+ } catch {
889
+ return;
890
+ }
891
+
892
+ for (const entry of entries) {
893
+ const fullPath = join(dir, entry.name);
894
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
895
+
896
+ if (entry.isDirectory()) {
897
+ if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
898
+ await _scanMetadataRefs(fullPath, referenced);
899
+ continue;
900
+ }
901
+
902
+ if (!entry.name.endsWith('.metadata.json')) continue;
614
903
 
615
904
  try {
616
- const raw = await readFile(metaPath, 'utf8');
617
- if (raw.trim()) {
618
- const meta = JSON.parse(raw);
619
- hasMetadata = true;
620
- isOnServer = !!meta._CreatedOn;
905
+ const raw = await readFile(fullPath, 'utf8');
906
+ if (!raw.trim()) continue;
907
+ const meta = JSON.parse(raw);
908
+ if (!meta._CreatedOn && !meta.UID) continue; // only count server-confirmed records
909
+
910
+ for (const col of (meta._contentColumns || [])) {
911
+ const val = meta[col];
912
+ if (typeof val === 'string' && val.startsWith('@')) {
913
+ const ref = val.substring(1);
914
+ if (ref.startsWith('/')) {
915
+ // @/path — absolute from project root
916
+ referenced.add(ref.substring(1));
917
+ } else {
918
+ // @filename — relative to metadata file's directory
919
+ const metaDir = relative(process.cwd(), dir).replace(/\\/g, '/');
920
+ const resolved = metaDir ? `${metaDir}/${ref}` : ref;
921
+ referenced.add(resolved);
922
+ }
923
+ }
621
924
  }
622
- } catch {
623
- // No metadata file
624
- }
625
925
 
626
- if (!isOnServer) {
627
- results.push(fullPath);
926
+ // Content records with a Path field may have their file at the project root
927
+ // (e.g. manifest.json is cloned to root but metadata lives in lib/bins/app/)
928
+ if (meta._entity === 'content' && meta.Path) {
929
+ const p = String(meta.Path).replace(/^\//, '');
930
+ referenced.add(p);
931
+ }
932
+ } catch {
933
+ // skip unreadable metadata
628
934
  }
629
935
  }
630
-
631
- return results;
632
936
  }
@@ -0,0 +1,102 @@
1
+ // src/commands/build.js
2
+ import { Command } from 'commander';
3
+ import { relative, basename } from 'path';
4
+ import { loadScripts, loadScriptsLocal, loadConfig, loadAppConfig } from '../lib/config.js';
5
+ import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle } from '../lib/scripts.js';
6
+ import { log } from '../lib/logger.js';
7
+
8
+ export const buildCommand = new Command('build')
9
+ .description('Run the build lifecycle (prebuild → build → postbuild) without pushing')
10
+ .argument('[path]', 'File or directory to build (default: all targets with a build hook)')
11
+ .option('--entity <type>', 'Run builds for all targets matching this entity type')
12
+ .action(async (targetPath, options) => {
13
+ try {
14
+ const base = await loadScripts();
15
+ const local = await loadScriptsLocal();
16
+
17
+ if (!base && !local) {
18
+ if (targetPath) {
19
+ log.warn('No .dbo/scripts.json found — nothing to build');
20
+ } else {
21
+ log.info('No .dbo/scripts.json found');
22
+ }
23
+ return;
24
+ }
25
+
26
+ const config = mergeScriptsConfig(base, local);
27
+ const cfg = await loadConfig();
28
+ const app = await loadAppConfig();
29
+ const appConfig = { ...app, domain: cfg.domain };
30
+
31
+ let targets = [];
32
+
33
+ if (targetPath) {
34
+ // Single explicit path
35
+ targets = [targetPath];
36
+ } else if (options.entity) {
37
+ // All targets in scripts.json that match the entity type
38
+ targets = Object.keys(config.targets).filter(t => {
39
+ const hooks = config.targets[t];
40
+ return hooks && hooks.build !== undefined;
41
+ });
42
+ if (targets.length === 0) {
43
+ log.info(`No targets with a build hook found for entity type "${options.entity}"`);
44
+ return;
45
+ }
46
+ } else {
47
+ // All targets in scripts.json that have a build hook
48
+ targets = Object.keys(config.targets).filter(t => {
49
+ const hooks = config.targets[t];
50
+ return hooks && hooks.build !== undefined;
51
+ });
52
+ // Also check global scripts for a build hook (runs once, not per-target)
53
+ if (config.scripts && config.scripts.build !== undefined) {
54
+ // Run global build once
55
+ const env = buildHookEnv('', '', appConfig);
56
+ const hooks = { prebuild: config.scripts.prebuild, build: config.scripts.build, postbuild: config.scripts.postbuild };
57
+ log.info('Running global build hooks...');
58
+ const ok = await runBuildLifecycle(hooks, env, process.cwd());
59
+ if (!ok) {
60
+ log.error('Global build hook failed');
61
+ process.exit(1);
62
+ }
63
+ if (targets.length === 0) return; // only global, done
64
+ }
65
+ if (targets.length === 0) {
66
+ log.info('No build hooks defined in .dbo/scripts.json targets');
67
+ return;
68
+ }
69
+ }
70
+
71
+ let failed = 0;
72
+ for (const t of targets) {
73
+ const relPath = t.replace(/\\/g, '/');
74
+ // Determine entity type from option or empty string
75
+ let entityType = '';
76
+ if (options.entity) entityType = options.entity;
77
+
78
+ const hooks = resolveHooks(relPath, entityType, config);
79
+ if (hooks.build === undefined && hooks.prebuild === undefined && hooks.postbuild === undefined) {
80
+ log.dim(` No build hooks for ${relPath} — skipping`);
81
+ continue;
82
+ }
83
+
84
+ log.info(`Building: ${relPath}`);
85
+ const env = buildHookEnv(relPath, entityType, appConfig);
86
+ const ok = await runBuildLifecycle(hooks, env, process.cwd());
87
+ if (!ok) {
88
+ log.error(`Build failed for "${relPath}"`);
89
+ failed++;
90
+ break; // fail-fast: first failure stops execution
91
+ }
92
+ log.success(` Built: ${relPath}`);
93
+ }
94
+
95
+ if (failed > 0) {
96
+ process.exit(1);
97
+ }
98
+ } catch (err) {
99
+ log.error(err.message);
100
+ process.exit(1);
101
+ }
102
+ });