@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.
@@ -1,46 +1,22 @@
1
- import { Command } from 'commander';
2
- import { readFile, readdir, stat, writeFile, mkdir, unlink } from 'fs/promises';
1
+ import { readFile, readdir, writeFile, mkdir, unlink } from 'fs/promises';
3
2
  import { join, dirname, basename, extname, relative } from 'path';
4
- import { DboClient } from '../lib/client.js';
5
- import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
6
- import { formatResponse, formatError } from '../lib/formatter.js';
7
- import { log } from '../lib/logger.js';
8
- import { shouldSkipColumn } from '../lib/columns.js';
9
- import { loadAppConfig, loadAppJsonBaseline, saveAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from '../lib/config.js';
10
- import { resolveDirective, resolveTemplateCols, assembleMetadata, promptReferenceColumn, setTemplateCols, saveMetadataTemplates, loadMetadataTemplates } from '../lib/metadata-templates.js';
11
- import { hasUidInFilename, buildMetaFilename, isMetadataFile } from '../lib/filenames.js';
12
- import { setFileTimestamps } from '../lib/timestamps.js';
13
- import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
14
- import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
15
- import { loadIgnore } from '../lib/ignore.js';
16
- import { loadStructureFile, findBinByPath, BINS_DIR } from '../lib/structure.js';
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
- ]);
3
+ import { DboClient } from './client.js';
4
+ import { buildInputBody, checkSubmitErrors } from './input-parser.js';
5
+ import { formatResponse } from './formatter.js';
6
+ import { log } from './logger.js';
7
+ import { shouldSkipColumn } from './columns.js';
8
+ import { loadAppConfig, loadAppJsonBaseline, saveAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from './config.js';
9
+ import { loadMetadataSchema, saveMetadataSchema } from './metadata-schema.js';
10
+ import { hasUidInFilename, buildMetaFilename, isMetadataFile } from './filenames.js';
11
+ import { setFileTimestamps } from './timestamps.js';
12
+ import { checkStoredTicket, clearGlobalTicket } from './ticketing.js';
13
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from './modify-key.js';
14
+ import { loadIgnore } from './ignore.js';
15
+ import { loadStructureFile, findBinByPath, BINS_DIR } from './structure.js';
16
+ import { upsertDeployEntry } from './deploy-config.js';
41
17
 
42
18
  /** Simple MIME type lookup for common media extensions */
43
- const MIME_TYPES = {
19
+ export const MIME_TYPES = {
44
20
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
45
21
  webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', svg: 'image/svg+xml',
46
22
  tiff: 'image/tiff', tif: 'image/tiff', avif: 'image/avif',
@@ -55,52 +31,66 @@ const MIME_TYPES = {
55
31
  tar: 'application/x-tar',
56
32
  };
57
33
 
58
- export const addCommand = new Command('add')
59
- .description('Add a new file to DBO.io (creates record on server)')
60
- .argument('<path>', 'File or "." to scan current directory')
61
- .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
62
- .option('--ticket <id>', 'Override ticket ID')
63
- .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
64
- .option('--row-key <type>', 'Row key type (RowUID or RowID) — add uses RowID:add1 for new records regardless')
65
- .option('-y, --yes', 'Auto-accept all prompts')
66
- .option('--json', 'Output raw JSON')
67
- .option('--jq <expr>', 'Filter JSON response')
68
- .option('-v, --verbose', 'Show HTTP request details')
69
- .option('--domain <host>', 'Override domain')
70
- .option('--no-migrate', 'Skip pending migrations for this invocation')
71
- .action(async (targetPath, options) => {
72
- try {
73
- await runPendingMigrations(options);
74
- const client = new DboClient({ domain: options.domain, verbose: options.verbose });
75
-
76
- // ModifyKey guard
77
- const modifyKeyResult = await checkModifyKey(options);
78
- if (modifyKeyResult.cancel) {
79
- log.info('Submission cancelled');
80
- return;
81
- }
82
- if (modifyKeyResult.modifyKey) options._resolvedModifyKey = modifyKeyResult.modifyKey;
83
-
84
- if (targetPath === '.') {
85
- await addDirectory(process.cwd(), client, options);
86
- } else {
87
- const pathStat = await stat(targetPath);
88
- if (pathStat.isDirectory()) {
89
- await addDirectory(targetPath, client, options);
90
- } else {
91
- await addSingleFile(targetPath, client, options);
92
- }
93
- }
94
- } catch (err) {
95
- formatError(err);
96
- process.exit(1);
97
- }
98
- });
34
+ /** Default template cols for core entity types */
35
+ const TEMPLATE_DEFAULTS = {
36
+ content: [
37
+ 'AppID', 'BinID', 'Name', 'Path', 'Content=@reference', 'Extension',
38
+ 'Type', 'Title', 'Active', 'Public=0', 'SiteID', 'Description',
39
+ 'Keywords', 'Parameters', 'CacheControl', 'RequireSSL', 'EntityID',
40
+ ],
41
+ media: [
42
+ 'AppID', 'BinID', 'Name', 'Path', 'Filename', 'Extension',
43
+ 'MimeType', 'FileSize', 'Ownership', 'Public', 'Description',
44
+ 'Duration', 'ParentMediaID',
45
+ ],
46
+ output: [
47
+ 'AppID', 'BinID', 'Name', 'Type', 'CustomSQL=@reference',
48
+ 'Active', 'Public', 'Title', 'Description', 'Parameters',
49
+ 'Cache', 'Limit', 'MaxRows', 'RowsPerPage', 'ApplyLocks',
50
+ 'AdhocFilters', 'BaseEntityID', 'DataSourceID',
51
+ 'DefaultMediaID', 'TemplateContentID', 'children',
52
+ ],
53
+ output_value: [
54
+ 'OutputID', 'Title', 'ShortName', 'OutputType', 'OrderNumber',
55
+ 'CustomSQL', 'Format', 'Aggregate', 'DatePart', 'Distinct',
56
+ 'Hide', 'Shared', 'Summary', 'SortType', 'SortOrder',
57
+ 'EnforceSecurity', 'ReferencedEntityColumnID',
58
+ 'OutputValueEntityColumnRelID', 'children',
59
+ ],
60
+ output_value_filter: [
61
+ 'OutputID', 'OutputValueID', 'ShortName', 'FilterType', 'Operator',
62
+ 'OrderNumber', 'CustomSQL', 'Prompted', 'Required', 'Inclusive',
63
+ 'PrimaryValue', 'SecondaryValue', 'FilterPreset',
64
+ 'ApplyToAggregate', 'ValuesByOutputID', 'children',
65
+ ],
66
+ output_value_entity_column_rel: [
67
+ 'OutputID', 'OutputValueID', 'JoinType', 'OrderNumber', 'CustomSQL',
68
+ 'Alias', 'SourceJoinID', 'SourceEntityColumnID',
69
+ 'ReferencedEntityColumnID', 'children',
70
+ ],
71
+ };
99
72
 
100
73
  /**
101
- * Add a single file to DBO.io.
102
- * Returns the defaults used (entity, contentColumn) for batch reuse.
74
+ * Seed .dbo/metadata_schema.json with default fields for the given entity
75
+ * (and any other missing core entities) if an entry doesn't already exist.
103
76
  */
77
+ export async function seedMetadataTemplate() {
78
+ const templates = await loadMetadataSchema() ?? {};
79
+
80
+ // Seed all missing core entities in one pass
81
+ const seeded = [];
82
+ for (const [name, cols] of Object.entries(TEMPLATE_DEFAULTS)) {
83
+ if (templates[name]) continue;
84
+ templates[name] = cols;
85
+ seeded.push(name);
86
+ }
87
+
88
+ if (seeded.length === 0) return;
89
+
90
+ await saveMetadataSchema(templates);
91
+ log.dim(` Seeded metadata_schema.json with ${seeded.join(', ')} fields`);
92
+ }
93
+
104
94
  /**
105
95
  * Detect whether a file lives in the project-root Documentation/ directory
106
96
  * AND the ExtensionDocumentationMDPlacement preference is 'root'.
@@ -108,7 +98,7 @@ export const addCommand = new Command('add')
108
98
  *
109
99
  * @param {string} filePath - Path to the file being added
110
100
  */
111
- async function detectDocumentationFile(filePath) {
101
+ export async function detectDocumentationFile(filePath) {
112
102
  const placement = await loadExtensionDocumentationMDPlacement();
113
103
  if (placement !== 'root') return null;
114
104
 
@@ -124,7 +114,7 @@ async function detectDocumentationFile(filePath) {
124
114
  *
125
115
  * Creates companion metadata in bins/app/ with pre-filled fields.
126
116
  */
127
- async function detectManifestFile(filePath) {
117
+ export async function detectManifestFile(filePath) {
128
118
  const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
129
119
  if (rel.toLowerCase() !== 'manifest.json') return null;
130
120
 
@@ -137,7 +127,7 @@ async function detectManifestFile(filePath) {
137
127
 
138
128
  const meta = {
139
129
  _entity: 'content',
140
- _contentColumns: ['Content'],
130
+ _companionReferenceColumns: ['Content'],
141
131
  Content: '@/manifest.json',
142
132
  Path: 'manifest.json',
143
133
  Name: 'manifest.json',
@@ -174,6 +164,26 @@ export async function detectBinFile(filePath) {
174
164
  const ext = extname(filePath).replace('.', '').toLowerCase();
175
165
  if (!ext) return null;
176
166
 
167
+ // Extensions treated as content entities (text-based, stored in content.Content)
168
+ const CONTENT_EXTENSIONS = new Set([
169
+ 'sh', 'js', 'json', 'html', 'txt', 'xml', 'css', 'md',
170
+ 'htm', 'svg', 'sql', 'csv', 'yaml', 'yml', 'ts', 'jsx', 'tsx',
171
+ ]);
172
+
173
+ // Extensions treated as media entities (binary files: images, audio, video)
174
+ const MEDIA_EXTENSIONS = new Set([
175
+ // Images
176
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif',
177
+ // Audio
178
+ 'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
179
+ // Video
180
+ 'mp4', 'webm', 'mov', 'avi', 'mkv', 'wmv', 'flv',
181
+ // Fonts
182
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
183
+ // Documents / archives (binary)
184
+ 'pdf', 'zip', 'gz', 'tar',
185
+ ]);
186
+
177
187
  const isContent = CONTENT_EXTENSIONS.has(ext);
178
188
  const isMedia = MEDIA_EXTENSIONS.has(ext);
179
189
  if (!isContent && !isMedia) return null;
@@ -202,7 +212,7 @@ export async function detectBinFile(filePath) {
202
212
 
203
213
  const meta = {
204
214
  _entity: 'content',
205
- _contentColumns: ['Content'],
215
+ _companionReferenceColumns: ['Content'],
206
216
  Name: base,
207
217
  Content: `@${fileName}`,
208
218
  Extension: ext.toUpperCase(),
@@ -216,7 +226,7 @@ export async function detectBinFile(filePath) {
216
226
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
217
227
  log.success(`Auto-created content metadata for "${fileName}"`);
218
228
 
219
- // Seed metadata_templates.json with content fields if missing
229
+ // Seed metadata_schema.json with content fields if missing
220
230
  await seedMetadataTemplate();
221
231
 
222
232
  return { meta, metaPath };
@@ -250,326 +260,18 @@ export async function detectBinFile(filePath) {
250
260
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
251
261
  log.success(`Auto-created media metadata for "${fileName}"`);
252
262
 
253
- // Seed metadata_templates.json with media fields if missing
263
+ // Seed metadata_schema.json with media fields if missing
254
264
  await seedMetadataTemplate();
255
265
 
256
266
  return { meta, metaPath };
257
267
  }
258
268
 
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
-
319
- async function addSingleFile(filePath, client, options, batchDefaults) {
320
- // Check .dboignore before doing any processing
321
- const ig = await loadIgnore();
322
- const relPath = relative(process.cwd(), filePath).replace(/\\/g, '/');
323
- if (ig.ignores(relPath)) {
324
- log.dim(`Skipped (dboignored): ${relPath}`);
325
- return null;
326
- }
327
-
328
- const dir = dirname(filePath);
329
- const ext = extname(filePath);
330
- const base = basename(filePath, ext);
331
- const fileName = basename(filePath);
332
- const metaPath = join(dir, `${base}.metadata.json`);
333
-
334
- // Step 1: Check for existing metadata
335
- let meta = null;
336
- try {
337
- const raw = await readFile(metaPath, 'utf8');
338
- if (raw.trim()) {
339
- meta = JSON.parse(raw);
340
- }
341
- } catch {
342
- // No metadata file — that's fine, we'll create one
343
- }
344
-
345
- // Step 2: If metadata exists with _CreatedOn, already on server
346
- if (meta && meta._CreatedOn) {
347
- log.warn(`"${fileName}" is already on the server (has _CreatedOn). Use "dbo push" to update.`);
348
- return null;
349
- }
350
-
351
- // Step 3: If metadata exists without _CreatedOn, use it for insert
352
- if (meta && meta._entity) {
353
- log.info(`Found metadata for "${fileName}" — proceeding with insert`);
354
- return await submitAdd(meta, metaPath, filePath, client, options);
355
- }
356
-
357
- // Step 3b: Directive / template path
358
- const directive = resolveDirective(filePath);
359
- if (directive) {
360
- const { entity, descriptor } = directive;
361
- const appConfig = await loadAppConfig();
362
- const appJson = await loadAppJsonBaseline().catch(() => null);
363
- const resolved = await resolveTemplateCols(entity, descriptor, appConfig, appJson);
364
-
365
- if (resolved !== null) {
366
- let { cols, templates } = resolved;
367
-
368
- // Check if @reference col is known
369
- const hasRefMarker = cols.some(c => c.includes('=@reference'));
370
- if (!hasRefMarker) {
371
- if (options.yes) {
372
- console.warn(`Warning: No @reference column in template for ${entity}${descriptor ? '.' + descriptor : ''} — skipping content column. Update .dbo/metadata_templates.json to add Key=@reference.`);
373
- } else {
374
- const chosen = await promptReferenceColumn(cols, entity, descriptor);
375
- if (chosen) {
376
- cols = cols.map(c => c === chosen ? `${chosen}=@reference` : c);
377
- templates = templates ?? await loadMetadataTemplates() ?? {};
378
- setTemplateCols(templates, entity, descriptor, cols);
379
- await saveMetadataTemplates(templates);
380
- }
381
- }
382
- }
383
-
384
- const { meta } = assembleMetadata(cols, filePath, entity, descriptor, appConfig);
385
-
386
- // Determine metadata path
387
- let templateMetaPath;
388
- const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
389
- if (rel.startsWith('docs/')) {
390
- // docs/ files: companion metadata goes to extension/documentation/
391
- const docDir = join(process.cwd(), 'extension', 'documentation');
392
- await mkdir(docDir, { recursive: true });
393
- templateMetaPath = join(docDir, `${basename(filePath, extname(filePath))}.metadata.json`);
394
- } else {
395
- templateMetaPath = join(dirname(filePath), `${basename(filePath, extname(filePath))}.metadata.json`);
396
- }
397
-
398
- await writeFile(templateMetaPath, JSON.stringify(meta, null, 2) + '\n');
399
- return await submitAdd(meta, templateMetaPath, filePath, client, options);
400
- }
401
- }
402
-
403
- // Step 3c: Auto-infer entity/descriptor for Documentation/ files when root placement is configured
404
- const docInfo = await detectDocumentationFile(filePath);
405
- if (docInfo) {
406
- const filenameCol = await loadDescriptorFilenamePreference('documentation') || 'Name';
407
- const docBase = basename(filePath, extname(filePath));
408
- const companionDir = join(process.cwd(), 'extension', 'documentation');
409
- await mkdir(companionDir, { recursive: true });
410
-
411
- const docMeta = {
412
- _entity: 'extension',
413
- Descriptor: 'documentation',
414
- [filenameCol]: docBase,
415
- Name: docBase,
416
- };
417
-
418
- // Find the content column name from saved preferences
419
- const contentCol = filenameCol === 'Name' ? 'String10' : 'String10';
420
- const relPath = relative(process.cwd(), filePath).replace(/\\/g, '/');
421
- docMeta[contentCol] = `@/${relPath}`;
422
- docMeta._contentColumns = [contentCol];
423
-
424
- const docMetaFile = join(companionDir, `${docBase}.metadata.json`);
425
- await writeFile(docMetaFile, JSON.stringify(docMeta, null, 2) + '\n');
426
- log.success(`Created companion metadata at ${docMetaFile}`);
427
- return await submitAdd(docMeta, docMetaFile, filePath, client, options);
428
- }
429
-
430
- // Step 3d: Auto-detect manifest.json at project root
431
- const manifestMeta = await detectManifestFile(filePath);
432
- if (manifestMeta) {
433
- return await submitAdd(manifestMeta.meta, manifestMeta.metaPath, filePath, client, options);
434
- }
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
-
442
- // Step 4: No usable metadata — interactive wizard
443
- const inquirer = (await import('inquirer')).default;
444
-
445
- log.plain('');
446
- log.warn(`I cannot add "${fileName}" to the server, because I don't have any metadata.`);
447
- log.plain(`I have a few things that I need, and I can set that up for you,`);
448
- log.plain(`or you manually add "${base}.metadata.json" next to the file.`);
449
- log.plain('');
450
-
451
- if (!options.yes) {
452
- const { proceed } = await inquirer.prompt([{
453
- type: 'confirm',
454
- name: 'proceed',
455
- message: 'Want me to set that up for you?',
456
- default: true,
457
- }]);
458
-
459
- if (!proceed) {
460
- log.dim(`Skipping "${fileName}". Create "${base}.metadata.json" manually and try again.`);
461
- return null;
462
- }
463
- }
464
-
465
- // Prompt for required fields (reuse batch defaults if available)
466
- // Check if config has AppID for smart default
467
- const appConfig = await loadAppConfig();
468
-
469
- const answers = await inquirer.prompt([
470
- {
471
- type: 'input',
472
- name: 'entity',
473
- message: 'Entity name (e.g. content, template, style):',
474
- default: batchDefaults?.entity || 'content',
475
- validate: v => v.trim() ? true : 'Entity is required',
476
- },
477
- {
478
- type: 'input',
479
- name: 'contentColumn',
480
- message: 'Column name for the file content (e.g. Content, Body, Template):',
481
- default: batchDefaults?.contentColumn || 'Content',
482
- validate: v => v.trim() ? true : 'Column name is required',
483
- },
484
- {
485
- type: 'input',
486
- name: 'BinID',
487
- message: 'BinID (optional, press Enter to skip):',
488
- default: batchDefaults?.BinID || '',
489
- },
490
- {
491
- type: 'input',
492
- name: 'SiteID',
493
- message: 'SiteID (optional, press Enter to skip):',
494
- default: batchDefaults?.SiteID || '',
495
- },
496
- {
497
- type: 'input',
498
- name: 'Path',
499
- message: 'Path (optional, press Enter for auto-generated):',
500
- default: batchDefaults?.Path || relative(process.cwd(), filePath).replace(/\\/g, '/'),
501
- },
502
- ]);
503
-
504
- // AppID: smart prompt when config has app info
505
- if (batchDefaults?.AppID) {
506
- answers.AppID = batchDefaults.AppID;
507
- } else if (appConfig.AppID) {
508
- const { appIdChoice } = await inquirer.prompt([{
509
- type: 'list',
510
- name: 'appIdChoice',
511
- message: `You're submitting data without an AppID, but your config has information about the current App. Do you want me to add that Column information along with your submission?`,
512
- choices: [
513
- { name: `Yes, use AppID ${appConfig.AppID}`, value: 'use_config' },
514
- { name: 'No', value: 'none' },
515
- { name: 'Enter custom AppID', value: 'custom' },
516
- ],
517
- }]);
518
- if (appIdChoice === 'use_config') {
519
- answers.AppID = String(appConfig.AppID);
520
- } else if (appIdChoice === 'custom') {
521
- const { customAppId } = await inquirer.prompt([{
522
- type: 'input', name: 'customAppId',
523
- message: 'Custom AppID:',
524
- }]);
525
- answers.AppID = customAppId;
526
- } else {
527
- answers.AppID = '';
528
- }
529
- } else {
530
- const { appId } = await inquirer.prompt([{
531
- type: 'input', name: 'appId',
532
- message: 'AppID (optional, press Enter to skip):',
533
- default: '',
534
- }]);
535
- answers.AppID = appId;
536
- }
537
-
538
- // Build metadata object
539
- meta = {};
540
- if (answers.AppID.trim()) meta.AppID = isFinite(answers.AppID) ? Number(answers.AppID) : answers.AppID;
541
- if (answers.BinID.trim()) meta.BinID = isFinite(answers.BinID) ? Number(answers.BinID) : answers.BinID;
542
- if (answers.SiteID.trim()) meta.SiteID = isFinite(answers.SiteID) ? Number(answers.SiteID) : answers.SiteID;
543
- meta.Name = base;
544
- if (answers.Path.trim()) meta.Path = answers.Path.trim();
545
- meta[answers.contentColumn.trim()] = `@${fileName}`;
546
- meta._entity = answers.entity.trim();
547
- meta._contentColumns = [answers.contentColumn.trim()];
548
-
549
- // Write metadata file
550
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
551
- log.success(`Created ${basename(metaPath)}`);
552
-
553
- // Submit the insert
554
- const defaults = {
555
- entity: answers.entity.trim(),
556
- contentColumn: answers.contentColumn.trim(),
557
- AppID: answers.AppID,
558
- BinID: answers.BinID,
559
- SiteID: answers.SiteID,
560
- Path: answers.Path,
561
- };
562
-
563
- await submitAdd(meta, metaPath, filePath, client, options);
564
- return defaults;
565
- }
566
-
567
269
  /**
568
270
  * Submit an insert to the server from a metadata object.
569
271
  */
570
272
  export async function submitAdd(meta, metaPath, filePath, client, options) {
571
273
  const entity = meta._entity;
572
- const contentCols = new Set(meta._contentColumns || []);
274
+ const contentCols = new Set(meta._companionReferenceColumns || meta._contentColumns || []);
573
275
  const metaDir = dirname(metaPath);
574
276
 
575
277
  if (!entity) {
@@ -691,7 +393,7 @@ export async function submitAdd(meta, metaPath, filePath, client, options) {
691
393
  if (serverTz && returnedLastUpdated) {
692
394
  try {
693
395
  await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
694
- for (const col of (meta._contentColumns || [])) {
396
+ for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
695
397
  const ref = meta[col];
696
398
  if (ref && String(ref).startsWith('@')) {
697
399
  const fp = join(metaDir, String(ref).substring(1));
@@ -716,7 +418,7 @@ export async function submitAdd(meta, metaPath, filePath, client, options) {
716
418
  return { entity: meta._entity };
717
419
  }
718
420
 
719
- async function updateBaselineWithNewRecord(meta, uid, lastUpdated) {
421
+ export async function updateBaselineWithNewRecord(meta, uid, lastUpdated) {
720
422
  try {
721
423
  const baseline = await loadAppJsonBaseline();
722
424
  if (!baseline) return;
@@ -741,68 +443,30 @@ async function updateBaselineWithNewRecord(meta, uid, lastUpdated) {
741
443
  }
742
444
 
743
445
  /**
744
- * Scan a directory for un-added files and add them.
446
+ * Collect all @ companion file references from a metadata object,
447
+ * including references nested inside children (inline output hierarchy).
745
448
  */
746
- async function addDirectory(dirPath, client, options) {
747
- const unadded = await findUnaddedFiles(dirPath);
748
-
749
- if (unadded.length === 0) {
750
- log.info('No un-added files found.');
751
- return;
752
- }
753
-
754
- log.info(`Found ${unadded.length} file(s) to add:`);
755
- for (const f of unadded) {
756
- log.plain(` ${relative(process.cwd(), f)}`);
757
- }
758
- log.plain('');
759
-
760
- if (!options.yes) {
761
- const inquirer = (await import('inquirer')).default;
762
- const { proceed } = await inquirer.prompt([{
763
- type: 'confirm',
764
- name: 'proceed',
765
- message: `Add ${unadded.length} file(s)?`,
766
- default: true,
767
- }]);
768
- if (!proceed) return;
769
- }
770
-
771
- // Pre-flight ticket validation
772
- if (!options.ticket) {
773
- const ticketCheck = await checkStoredTicket(options);
774
- if (ticketCheck.cancel) {
775
- log.info('Submission cancelled');
776
- return;
777
- }
778
- if (ticketCheck.clearTicket) {
779
- await clearGlobalTicket();
780
- log.dim(' Cleared stored ticket');
449
+ function collectCompanionRefs(meta, refs) {
450
+ for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
451
+ const val = meta[col];
452
+ if (typeof val === 'string' && val.startsWith('@') && !val.startsWith('@/')) {
453
+ refs.add(val.substring(1));
781
454
  }
782
455
  }
783
-
784
- let succeeded = 0;
785
- let failed = 0;
786
- let batchDefaults = null;
787
-
788
- for (const filePath of unadded) {
789
- try {
790
- const result = await addSingleFile(filePath, client, options, batchDefaults);
791
- if (result) {
792
- batchDefaults = result;
793
- succeeded++;
794
- }
795
- } catch (err) {
796
- if (err.message === 'SKIP_ALL') {
797
- log.info('Skipping remaining records');
798
- break;
456
+ if (meta._mediaFile && typeof meta._mediaFile === 'string' && meta._mediaFile.startsWith('@')) {
457
+ refs.add(meta._mediaFile.substring(1));
458
+ }
459
+ // Recurse into inline output children (column, join, filter arrays)
460
+ if (meta.children && typeof meta.children === 'object' && !Array.isArray(meta.children)) {
461
+ for (const arr of Object.values(meta.children)) {
462
+ if (!Array.isArray(arr)) continue;
463
+ for (const child of arr) {
464
+ if (child && typeof child === 'object') {
465
+ collectCompanionRefs(child, refs);
466
+ }
799
467
  }
800
- log.error(`Failed: ${relative(process.cwd(), filePath)} — ${err.message}`);
801
- failed++;
802
468
  }
803
469
  }
804
-
805
- log.info(`Add complete: ${succeeded} succeeded, ${failed} failed`);
806
470
  }
807
471
 
808
472
  /**
@@ -835,15 +499,7 @@ export async function findUnaddedFiles(dir, ig, referencedFiles) {
835
499
  const meta = JSON.parse(raw);
836
500
  // Only count records that are on the server (have UID or _CreatedOn)
837
501
  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
- }
502
+ collectCompanionRefs(meta, localRefs);
847
503
  } catch { /* skip unreadable */ }
848
504
  }
849
505
 
@@ -912,19 +568,18 @@ async function _scanMetadataRefs(dir, referenced) {
912
568
  const meta = JSON.parse(raw);
913
569
  if (!meta._CreatedOn && !meta.UID) continue; // only count server-confirmed records
914
570
 
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
- }
571
+ // Collect all @ references (including from inline output children)
572
+ const allRefs = new Set();
573
+ collectCompanionRefs(meta, allRefs);
574
+ for (const refName of allRefs) {
575
+ if (refName.startsWith('/')) {
576
+ // @/path — absolute from project root
577
+ referenced.add(refName.substring(1));
578
+ } else {
579
+ // @filename — relative to metadata file's directory
580
+ const metaDir = relative(process.cwd(), dir).replace(/\\/g, '/');
581
+ const resolved = metaDir ? `${metaDir}/${refName}` : refName;
582
+ referenced.add(resolved);
928
583
  }
929
584
  }
930
585