@dboio/cli 0.11.4 → 0.15.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.
Files changed (57) hide show
  1. package/README.md +183 -3
  2. package/bin/dbo.js +6 -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 +66 -243
  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 +2279 -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 +63 -246
  31. package/src/commands/add.js +373 -64
  32. package/src/commands/build.js +102 -0
  33. package/src/commands/clone.js +719 -212
  34. package/src/commands/deploy.js +9 -2
  35. package/src/commands/diff.js +7 -3
  36. package/src/commands/init.js +16 -2
  37. package/src/commands/input.js +3 -1
  38. package/src/commands/login.js +30 -4
  39. package/src/commands/mv.js +28 -7
  40. package/src/commands/push.js +298 -78
  41. package/src/commands/rm.js +21 -6
  42. package/src/commands/run.js +81 -0
  43. package/src/commands/tag.js +65 -0
  44. package/src/lib/config.js +67 -0
  45. package/src/lib/delta.js +7 -1
  46. package/src/lib/deploy-config.js +137 -0
  47. package/src/lib/diff.js +28 -5
  48. package/src/lib/filenames.js +198 -54
  49. package/src/lib/ignore.js +6 -0
  50. package/src/lib/input-parser.js +13 -4
  51. package/src/lib/scaffold.js +1 -1
  52. package/src/lib/scripts.js +232 -0
  53. package/src/lib/tagging.js +380 -0
  54. package/src/lib/toe-stepping.js +2 -1
  55. package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
  56. package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
  57. package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
@@ -1,20 +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
- import { buildUidFilename, hasUidInFilename } from '../lib/filenames.js';
11
+ import { hasUidInFilename, buildMetaFilename, isMetadataFile } 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
+ import { upsertDeployEntry } from '../lib/deploy-config.js';
19
+
20
+ // ─── File type classification for auto-detection in bin directories ────────
21
+
22
+ /** Extensions treated as content entities (text-based, stored in content.Content) */
23
+ const CONTENT_EXTENSIONS = new Set([
24
+ 'sh', 'js', 'json', 'html', 'txt', 'xml', 'css', 'md',
25
+ 'htm', 'svg', 'sql', 'csv', 'yaml', 'yml', 'ts', 'jsx', 'tsx',
26
+ ]);
27
+
28
+ /** Extensions treated as media entities (binary files: images, audio, video) */
29
+ const MEDIA_EXTENSIONS = new Set([
30
+ // Images
31
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif',
32
+ // Audio
33
+ 'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
34
+ // Video
35
+ 'mp4', 'webm', 'mov', 'avi', 'mkv', 'wmv', 'flv',
36
+ // Fonts
37
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
38
+ // Documents / archives (binary)
39
+ 'pdf', 'zip', 'gz', 'tar',
40
+ ]);
41
+
42
+ /** Simple MIME type lookup for common media extensions */
43
+ const MIME_TYPES = {
44
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
45
+ webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', svg: 'image/svg+xml',
46
+ tiff: 'image/tiff', tif: 'image/tiff', avif: 'image/avif',
47
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', aac: 'audio/aac',
48
+ flac: 'audio/flac', m4a: 'audio/mp4', wma: 'audio/x-ms-wma',
49
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
50
+ avi: 'video/x-msvideo', mkv: 'video/x-matroska', wmv: 'video/x-ms-wmv',
51
+ flv: 'video/x-flv',
52
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
53
+ eot: 'application/vnd.ms-fontobject',
54
+ pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
55
+ tar: 'application/x-tar',
56
+ };
18
57
 
19
58
  export const addCommand = new Command('add')
20
59
  .description('Add a new file to DBO.io (creates record on server)')
@@ -118,6 +157,165 @@ async function detectManifestFile(filePath) {
118
157
  return { meta, metaPath };
119
158
  }
120
159
 
160
+ /**
161
+ * Detect whether a file lives inside lib/bins/ and auto-classify as content or media.
162
+ *
163
+ * Content extensions: sh, js, json, html, txt, xml, css, md, etc.
164
+ * Media extensions: png, jpg, webp, mp4, woff2, pdf, etc.
165
+ *
166
+ * Returns { meta, metaPath } or null if not applicable.
167
+ */
168
+ export async function detectBinFile(filePath) {
169
+ const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
170
+
171
+ // Must be inside lib/bins/ (or legacy Bins/)
172
+ if (!rel.startsWith(BINS_DIR + '/') && !rel.toLowerCase().startsWith('bins/')) return null;
173
+
174
+ const ext = extname(filePath).replace('.', '').toLowerCase();
175
+ if (!ext) return null;
176
+
177
+ const isContent = CONTENT_EXTENSIONS.has(ext);
178
+ const isMedia = MEDIA_EXTENSIONS.has(ext);
179
+ if (!isContent && !isMedia) return null;
180
+
181
+ const appConfig = await loadAppConfig();
182
+ const structure = await loadStructureFile();
183
+
184
+ // Resolve BinID from the file's parent directory
185
+ const fileDir = dirname(rel);
186
+ const bin = findBinByPath(fileDir, structure);
187
+
188
+ const fileName = basename(filePath);
189
+ const base = basename(filePath, extname(filePath));
190
+
191
+ // Build metadata path next to the file
192
+ const metaPath = join(dirname(filePath), `${base}.metadata.json`);
193
+
194
+ // Use the bin's server-side Path field (e.g. "assets/css", "assets/gfx")
195
+ const binPath = bin?.path || '';
196
+
197
+ if (isContent) {
198
+ // content.Name = filename without extension, content.Path = <binPath>/<Name>.<Extension>
199
+ const contentPath = binPath
200
+ ? `${binPath}/${base}.${ext.toLowerCase()}`
201
+ : `${base}.${ext.toLowerCase()}`;
202
+
203
+ const meta = {
204
+ _entity: 'content',
205
+ _contentColumns: ['Content'],
206
+ Name: base,
207
+ Content: `@${fileName}`,
208
+ Extension: ext.toUpperCase(),
209
+ Path: contentPath,
210
+ Active: 1,
211
+ Public: 0,
212
+ };
213
+ if (bin) meta.BinID = bin.binId;
214
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
215
+
216
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
217
+ log.success(`Auto-created content metadata for "${fileName}"`);
218
+
219
+ // Seed metadata_templates.json with content fields if missing
220
+ await seedMetadataTemplate();
221
+
222
+ return { meta, metaPath };
223
+ }
224
+
225
+ // Media: media.Name = filename without extension, media.Filename = filename with extension
226
+ // media.Path = bin directory path, media.FullPath = /media/<AppShortName>/app/<binPath>/<Filename>
227
+ const meta = {
228
+ _entity: 'media',
229
+ _mediaFile: `@${fileName}`,
230
+ Name: base,
231
+ Filename: fileName,
232
+ Extension: ext,
233
+ Ownership: 'App',
234
+ };
235
+ if (bin) meta.BinID = bin.binId;
236
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
237
+ if (binPath) meta.Path = binPath;
238
+
239
+ const mimeType = MIME_TYPES[ext];
240
+ if (mimeType) meta.MimeType = mimeType;
241
+
242
+ // Build FullPath: /media/<AppShortName>/app/<binPath>/<Filename>
243
+ if (appConfig.AppShortName) {
244
+ const fullPathParts = ['', 'media', appConfig.AppShortName, 'app'];
245
+ if (binPath) fullPathParts.push(binPath);
246
+ fullPathParts.push(fileName);
247
+ meta.FullPath = fullPathParts.join('/');
248
+ }
249
+
250
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
251
+ log.success(`Auto-created media metadata for "${fileName}"`);
252
+
253
+ // Seed metadata_templates.json with media fields if missing
254
+ await seedMetadataTemplate();
255
+
256
+ return { meta, metaPath };
257
+ }
258
+
259
+ /** Default template cols for core entity types */
260
+ const TEMPLATE_DEFAULTS = {
261
+ content: [
262
+ 'AppID', 'BinID', 'Name', 'Path', 'Content=@reference', 'Extension',
263
+ 'Type', 'Title', 'Active', 'Public=0', 'SiteID', 'Description',
264
+ 'Keywords', 'Parameters', 'CacheControl', 'RequireSSL', 'EntityID',
265
+ ],
266
+ media: [
267
+ 'AppID', 'BinID', 'Name', 'Path', 'Filename', 'Extension',
268
+ 'MimeType', 'FileSize', 'Ownership', 'Public', 'Description',
269
+ 'Duration', 'ParentMediaID',
270
+ ],
271
+ output: [
272
+ 'AppID', 'BinID', 'Name', 'Type', 'CustomSQL=@reference',
273
+ 'Active', 'Public', 'Title', 'Description', 'Parameters',
274
+ 'Cache', 'Limit', 'MaxRows', 'RowsPerPage', 'ApplyLocks',
275
+ 'AdhocFilters', 'BaseEntityID', 'DataSourceID',
276
+ 'DefaultMediaID', 'TemplateContentID', 'children',
277
+ ],
278
+ output_value: [
279
+ 'OutputID', 'Title', 'ShortName', 'OutputType', 'OrderNumber',
280
+ 'CustomSQL', 'Format', 'Aggregate', 'DatePart', 'Distinct',
281
+ 'Hide', 'Shared', 'Summary', 'SortType', 'SortOrder',
282
+ 'EnforceSecurity', 'ReferencedEntityColumnID',
283
+ 'OutputValueEntityColumnRelID', 'children',
284
+ ],
285
+ output_value_filter: [
286
+ 'OutputID', 'OutputValueID', 'ShortName', 'FilterType', 'Operator',
287
+ 'OrderNumber', 'CustomSQL', 'Prompted', 'Required', 'Inclusive',
288
+ 'PrimaryValue', 'SecondaryValue', 'FilterPreset',
289
+ 'ApplyToAggregate', 'ValuesByOutputID', 'children',
290
+ ],
291
+ output_value_entity_column_rel: [
292
+ 'OutputID', 'OutputValueID', 'JoinType', 'OrderNumber', 'CustomSQL',
293
+ 'Alias', 'SourceJoinID', 'SourceEntityColumnID',
294
+ 'ReferencedEntityColumnID', 'children',
295
+ ],
296
+ };
297
+
298
+ /**
299
+ * Seed .dbo/metadata_templates.json with default fields for the given entity
300
+ * (and any other missing core entities) if an entry doesn't already exist.
301
+ */
302
+ async function seedMetadataTemplate() {
303
+ const templates = await loadMetadataTemplates() ?? {};
304
+
305
+ // Seed all missing core entities in one pass
306
+ const seeded = [];
307
+ for (const [name, cols] of Object.entries(TEMPLATE_DEFAULTS)) {
308
+ if (templates[name]) continue;
309
+ templates[name] = cols;
310
+ seeded.push(name);
311
+ }
312
+
313
+ if (seeded.length === 0) return;
314
+
315
+ await saveMetadataTemplates(templates);
316
+ log.dim(` Seeded metadata_templates.json with ${seeded.join(', ')} fields`);
317
+ }
318
+
121
319
  async function addSingleFile(filePath, client, options, batchDefaults) {
122
320
  // Check .dboignore before doing any processing
123
321
  const ig = await loadIgnore();
@@ -235,6 +433,12 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
235
433
  return await submitAdd(manifestMeta.meta, manifestMeta.metaPath, filePath, client, options);
236
434
  }
237
435
 
436
+ // Step 3e: Auto-detect content/media files in bin directories (lib/bins/)
437
+ const binMeta = await detectBinFile(filePath);
438
+ if (binMeta) {
439
+ return await submitAdd(binMeta.meta, binMeta.metaPath, filePath, client, options);
440
+ }
441
+
238
442
  // Step 4: No usable metadata — interactive wizard
239
443
  const inquirer = (await import('inquirer')).default;
240
444
 
@@ -363,7 +567,7 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
363
567
  /**
364
568
  * Submit an insert to the server from a metadata object.
365
569
  */
366
- async function submitAdd(meta, metaPath, filePath, client, options) {
570
+ export async function submitAdd(meta, metaPath, filePath, client, options) {
367
571
  const entity = meta._entity;
368
572
  const contentCols = new Set(meta._contentColumns || []);
369
573
  const metaDir = dirname(metaPath);
@@ -442,11 +646,22 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
442
646
  throw new Error('Add failed');
443
647
  }
444
648
 
445
- // Extract UID from response and rename files to ~uid convention
649
+ // Extract UID and server-populated fields from response, merge into metadata
446
650
  const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
447
651
  if (addResults.length > 0) {
448
- const returnedUID = addResults[0].UID;
449
- const returnedLastUpdated = addResults[0]._LastUpdated;
652
+ const serverRecord = addResults[0];
653
+ const returnedUID = serverRecord.UID;
654
+ const returnedLastUpdated = serverRecord._LastUpdated;
655
+
656
+ // Merge server-populated fields into meta (e.g. _id, ContentID, _LastUpdated, _CreatedOn)
657
+ for (const [key, value] of Object.entries(serverRecord)) {
658
+ // Skip internal/transient fields and arrays (Physical, Errors)
659
+ if (Array.isArray(value)) continue;
660
+ if (key === 'entity' || key === 'RowID') continue;
661
+ // Don't overwrite existing content/media references or private fields we manage
662
+ if (meta[key] !== undefined && !key.startsWith('_')) continue;
663
+ meta[key] = value;
664
+ }
450
665
 
451
666
  if (returnedUID) {
452
667
  // Check if UID is already embedded in the filename (idempotency guard)
@@ -456,54 +671,43 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
456
671
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
457
672
  log.success(`UID already in filename: ${returnedUID}`);
458
673
  } else {
459
- // Compute new names
674
+ // Companion file NOT renamed — only update UID in metadata and rename metadata file
460
675
  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
- }
676
+ const metaBase = basename(metaPath, '.metadata.json');
677
+ const newMetaPath = join(metaDir, buildMetaFilename(metaBase, returnedUID));
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
+ await upsertDeployEntry(fp, returnedUID, entity, col);
699
+ try { await setFileTimestamps(fp, returnedLastUpdated, returnedLastUpdated, serverTz); } catch {}
700
+ }
701
+ }
702
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
703
+ const mediaFp = join(metaDir, String(meta._mediaFile).substring(1));
704
+ await upsertDeployEntry(mediaFp, returnedUID, entity, 'File');
705
+ }
502
706
  } catch { /* non-critical */ }
503
707
  }
504
708
 
505
- log.success(`UID assigned: ${returnedUID}`);
506
- log.dim(` Files renamed to ~${returnedUID} convention`);
709
+ // Update .app_baseline.json so subsequent pull/diff recognize this record
710
+ await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
507
711
  }
508
712
  log.dim(` Run "dbo pull -e ${entity} ${returnedUID}" to populate all columns`);
509
713
  }
@@ -512,6 +716,30 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
512
716
  return { entity: meta._entity };
513
717
  }
514
718
 
719
+ async function updateBaselineWithNewRecord(meta, uid, lastUpdated) {
720
+ try {
721
+ const baseline = await loadAppJsonBaseline();
722
+ if (!baseline) return;
723
+ const entity = meta._entity;
724
+ if (!entity) return;
725
+ if (!baseline.children) baseline.children = {};
726
+ if (!baseline.children[entity]) baseline.children[entity] = [];
727
+
728
+ const arr = baseline.children[entity];
729
+ const existingIdx = arr.findIndex(r => r.UID === uid || (meta._id && r._id === meta._id));
730
+ const entry = { ...meta, UID: uid };
731
+ if (lastUpdated) entry._LastUpdated = lastUpdated;
732
+
733
+ if (existingIdx >= 0) {
734
+ arr[existingIdx] = { ...arr[existingIdx], ...entry };
735
+ } else {
736
+ arr.push(entry);
737
+ }
738
+
739
+ await saveAppJsonBaseline(baseline);
740
+ } catch { /* baseline update is non-critical */ }
741
+ }
742
+
515
743
  /**
516
744
  * Scan a directory for un-added files and add them.
517
745
  */
@@ -579,54 +807,135 @@ async function addDirectory(dirPath, client, options) {
579
807
 
580
808
  /**
581
809
  * 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)
810
+ * A file is "un-added" if no *.metadata.json references it via @.
811
+ *
812
+ * Detection uses two layers:
813
+ * 1. Per-directory: scan sibling *.metadata.json files (handles ~UID naming)
814
+ * 2. Project-wide: buildReferencedFileSet catches cross-directory @/ references
585
815
  */
586
- async function findUnaddedFiles(dir, ig) {
816
+ export async function findUnaddedFiles(dir, ig, referencedFiles) {
587
817
  if (!ig) ig = await loadIgnore();
588
818
 
819
+ // On first call, build the set of files referenced by existing metadata
820
+ if (!referencedFiles) {
821
+ referencedFiles = await buildReferencedFileSet();
822
+ }
823
+
589
824
  const results = [];
590
825
  const entries = await readdir(dir, { withFileTypes: true });
591
826
 
827
+ // Build a set of filenames referenced by sibling metadata in this directory.
828
+ // This handles the ~UID naming convention: colors.metadata~uid.json → @colors.css
829
+ const localRefs = new Set();
830
+ for (const entry of entries) {
831
+ if (!isMetadataFile(entry.name)) continue;
832
+ try {
833
+ const raw = await readFile(join(dir, entry.name), 'utf8');
834
+ if (!raw.trim()) continue;
835
+ const meta = JSON.parse(raw);
836
+ // Only count records that are on the server (have UID or _CreatedOn)
837
+ if (!meta.UID && !meta._CreatedOn) continue;
838
+ for (const col of (meta._contentColumns || [])) {
839
+ const val = meta[col];
840
+ if (typeof val === 'string' && val.startsWith('@') && !val.startsWith('@/')) {
841
+ localRefs.add(val.substring(1));
842
+ }
843
+ }
844
+ if (meta._mediaFile && typeof meta._mediaFile === 'string' && meta._mediaFile.startsWith('@')) {
845
+ localRefs.add(meta._mediaFile.substring(1));
846
+ }
847
+ } catch { /* skip unreadable */ }
848
+ }
849
+
592
850
  for (const entry of entries) {
593
851
  const fullPath = join(dir, entry.name);
594
852
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
595
853
 
596
854
  if (entry.isDirectory()) {
597
855
  if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
598
- results.push(...await findUnaddedFiles(fullPath, ig));
856
+ results.push(...await findUnaddedFiles(fullPath, ig, referencedFiles));
599
857
  continue;
600
858
  }
601
859
 
602
- // Skip metadata files themselves (structural, not user-configurable)
603
- if (entry.name.endsWith('.metadata.json')) continue;
860
+ // Skip metadata files themselves
861
+ if (isMetadataFile(entry.name)) continue;
604
862
  // Skip ignored files
605
863
  if (ig.ignores(relPath)) continue;
606
864
 
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`);
865
+ // Check 1: sibling metadata references this file via @
866
+ if (localRefs.has(entry.name)) continue;
867
+
868
+ // Check 2: project-wide cross-directory @/ references
869
+ if (referencedFiles.has(relPath)) continue;
611
870
 
612
- let hasMetadata = false;
613
- let isOnServer = false;
871
+ results.push(fullPath);
872
+ }
873
+
874
+ return results;
875
+ }
876
+
877
+ /**
878
+ * Scan all metadata files and build a set of content files they reference.
879
+ * This catches files like docs/Chat.md whose metadata lives elsewhere
880
+ * (e.g. lib/extension/Documentation/Chat~uid.metadata.json with "@/docs/Chat.md").
881
+ */
882
+ async function buildReferencedFileSet() {
883
+ const referenced = new Set();
884
+ await _scanMetadataRefs(process.cwd(), referenced);
885
+ return referenced;
886
+ }
887
+
888
+ async function _scanMetadataRefs(dir, referenced) {
889
+ const ig = await loadIgnore();
890
+ let entries;
891
+ try {
892
+ entries = await readdir(dir, { withFileTypes: true });
893
+ } catch {
894
+ return;
895
+ }
896
+
897
+ for (const entry of entries) {
898
+ const fullPath = join(dir, entry.name);
899
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
900
+
901
+ if (entry.isDirectory()) {
902
+ if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
903
+ await _scanMetadataRefs(fullPath, referenced);
904
+ continue;
905
+ }
906
+
907
+ if (!isMetadataFile(entry.name)) continue;
614
908
 
615
909
  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;
910
+ const raw = await readFile(fullPath, 'utf8');
911
+ if (!raw.trim()) continue;
912
+ const meta = JSON.parse(raw);
913
+ if (!meta._CreatedOn && !meta.UID) continue; // only count server-confirmed records
914
+
915
+ for (const col of (meta._contentColumns || [])) {
916
+ const val = meta[col];
917
+ if (typeof val === 'string' && val.startsWith('@')) {
918
+ const ref = val.substring(1);
919
+ if (ref.startsWith('/')) {
920
+ // @/path — absolute from project root
921
+ referenced.add(ref.substring(1));
922
+ } else {
923
+ // @filename — relative to metadata file's directory
924
+ const metaDir = relative(process.cwd(), dir).replace(/\\/g, '/');
925
+ const resolved = metaDir ? `${metaDir}/${ref}` : ref;
926
+ referenced.add(resolved);
927
+ }
928
+ }
621
929
  }
622
- } catch {
623
- // No metadata file
624
- }
625
930
 
626
- if (!isOnServer) {
627
- results.push(fullPath);
931
+ // Content records with a Path field may have their file at the project root
932
+ // (e.g. manifest.json is cloned to root but metadata lives in lib/bins/app/)
933
+ if (meta._entity === 'content' && meta.Path) {
934
+ const p = String(meta.Path).replace(/^\//, '');
935
+ referenced.add(p);
936
+ }
937
+ } catch {
938
+ // skip unreadable metadata
628
939
  }
629
940
  }
630
-
631
- return results;
632
941
  }
@@ -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
+ });