@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
@@ -6,19 +6,31 @@ import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../li
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 { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
9
+ import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal } from '../lib/config.js';
10
+ import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle, runPushLifecycle } from '../lib/scripts.js';
10
11
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
12
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
12
13
  import { resolveTransactionKey } from '../lib/transaction-key.js';
13
14
  import { setFileTimestamps } from '../lib/timestamps.js';
14
- import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, buildUidFilename } from '../lib/filenames.js';
15
+ import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
15
16
  import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
16
17
  import { loadIgnore } from '../lib/ignore.js';
17
18
  import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
18
19
  import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '../lib/structure.js';
19
- import { ensureTrashIcon } from '../lib/folder-icon.js';
20
+ import { ensureTrashIcon, setFileTag } from '../lib/tagging.js';
20
21
  import { checkToeStepping } from '../lib/toe-stepping.js';
21
22
  import { runPendingMigrations } from '../lib/migrations.js';
23
+ import { findUnaddedFiles, detectBinFile, submitAdd } from './add.js';
24
+
25
+ function _getMetaCompanionPaths(meta, metaPath) {
26
+ const dir = dirname(metaPath);
27
+ const paths = [];
28
+ for (const col of (meta._contentColumns || [])) {
29
+ const ref = meta[col];
30
+ if (ref && String(ref).startsWith('@')) paths.push(join(dir, String(ref).substring(1)));
31
+ }
32
+ return paths;
33
+ }
22
34
 
23
35
  /**
24
36
  * Resolve an @reference file path to an absolute filesystem path.
@@ -42,9 +54,16 @@ function isToeStepping(options) {
42
54
 
43
55
  import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
44
56
 
57
+ async function loadAndMergeScripts() {
58
+ const base = await loadScripts();
59
+ const local = await loadScriptsLocal();
60
+ if (!base && !local) return null;
61
+ return mergeScriptsConfig(base, local);
62
+ }
63
+
45
64
  export const pushCommand = new Command('push')
46
65
  .description('Push local files back to DBO.io using metadata from pull')
47
- .argument('<path>', 'File or directory to push')
66
+ .argument('[path]', 'File or directory to push (default: current directory)')
48
67
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
49
68
  .option('--ticket <id>', 'Override ticket ID')
50
69
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
@@ -58,7 +77,9 @@ export const pushCommand = new Command('push')
58
77
  .option('-v, --verbose', 'Show HTTP request details')
59
78
  .option('--domain <host>', 'Override domain')
60
79
  .option('--no-migrate', 'Skip pending migrations for this invocation')
61
- .action(async (targetPath, options) => {
80
+ .option('--no-scripts', 'Bypass all script hooks; run default push pipeline unconditionally')
81
+ .option('--no-build', 'Skip the build phase (prebuild/build/postbuild); run push phase only')
82
+ .action(async (targetPath = ".", options) => {
62
83
  try {
63
84
  await runPendingMigrations(options);
64
85
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
@@ -169,18 +190,29 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
169
190
  const body = await buildInputBody([entry.expression], extraParams);
170
191
 
171
192
  try {
172
- const result = await client.postUrlEncoded('/api/input/submit', body);
173
-
174
- // Deletes never require ticketing treat ticket-only errors as success
175
- const deleteMessages = (result.messages || result.data?.Messages || [])
176
- .filter(m => typeof m === 'string');
177
- const isTicketOnlyError = !result.successful
178
- && deleteMessages.length > 0
179
- && deleteMessages.every(m => m.includes('ticket_error') || m.includes('ticket_lookup'));
180
- const deleteOk = result.successful || isTicketOnlyError;
181
-
182
- if (deleteOk) {
183
- if (isTicketOnlyError) log.dim(' (Ticket error ignored for delete)');
193
+ let result = await client.postUrlEncoded('/api/input/submit', body);
194
+
195
+ // Retry with prompted params if needed (ticket, repo mismatch, user)
196
+ if (!result.successful) {
197
+ const retryResult = await checkSubmitErrors(result, { rowUid: entry.UID });
198
+ if (retryResult) {
199
+ if (retryResult.skipRecord) { remaining.push(entry); continue; }
200
+ if (retryResult.skipAll) break;
201
+ if (retryResult.ticketExpressions?.length > 0) {
202
+ // Re-build body with ticket expressions added to the delete expression
203
+ const allExprs = [entry.expression, ...retryResult.ticketExpressions];
204
+ const retryParams = { ...extraParams, ...(retryResult.retryParams || retryResult) };
205
+ const retryBody = await buildInputBody(allExprs, retryParams);
206
+ result = await client.postUrlEncoded('/api/input/submit', retryBody);
207
+ } else {
208
+ const retryParams = { ...extraParams, ...(retryResult.retryParams || retryResult) };
209
+ const retryBody = await buildInputBody([entry.expression], retryParams);
210
+ result = await client.postUrlEncoded('/api/input/submit', retryBody);
211
+ }
212
+ }
213
+ }
214
+
215
+ if (result.successful) {
184
216
  log.success(` Deleted "${entry.name}" from server`);
185
217
  deletedUids.push(entry.UID);
186
218
  } else {
@@ -284,21 +316,55 @@ async function moveWillDeleteToTrash(entry) {
284
316
  async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
285
317
  // Find the metadata file
286
318
  let metaPath;
287
- if (filePath.endsWith('.metadata.json')) {
319
+ if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
288
320
  // User passed the metadata file directly — use it as-is
289
321
  metaPath = filePath;
290
322
  } else {
291
- const dir = dirname(filePath);
292
- const base = basename(filePath, extname(filePath));
293
- metaPath = join(dir, `${base}.metadata.json`);
323
+ // Try findMetadataForCompanion first (handles both new and legacy formats)
324
+ const found = await findMetadataForCompanion(filePath);
325
+ if (found) {
326
+ metaPath = found;
327
+ } else {
328
+ // Fallback: old convention
329
+ const dir = dirname(filePath);
330
+ const base = basename(filePath, extname(filePath));
331
+ metaPath = join(dir, `${base}.metadata.json`);
332
+ }
294
333
  }
295
334
 
296
335
  let meta;
297
336
  try {
298
337
  meta = JSON.parse(await readFile(metaPath, 'utf8'));
299
338
  } catch {
300
- log.error(`No metadata found at "${metaPath}". Pull the record first with "dbo content pull" or "dbo output --save".`);
301
- process.exit(1);
339
+ // Direct metadata path not found search by @reference
340
+ const found = await findMetadataForCompanion(filePath);
341
+ if (found) {
342
+ metaPath = found;
343
+ try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch {}
344
+ }
345
+ if (!meta) {
346
+ // Try auto-detecting as a bin content/media file and add it first
347
+ const binMeta = await detectBinFile(filePath);
348
+ if (binMeta) {
349
+ log.info(`No metadata found — auto-adding "${basename(filePath)}" first`);
350
+ try {
351
+ await submitAdd(binMeta.meta, binMeta.metaPath, filePath, client, options);
352
+ // After successful add, re-read the metadata (now has UID)
353
+ metaPath = binMeta.metaPath;
354
+ // The metadata file may have been renamed with ~UID, so scan for it
355
+ const updatedMeta = await findMetadataForCompanion(filePath);
356
+ if (updatedMeta) metaPath = updatedMeta;
357
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
358
+ log.info(`Successfully added — now pushing updates`);
359
+ } catch (err) {
360
+ log.error(`Auto-add failed for "${basename(filePath)}": ${err.message}`);
361
+ process.exit(1);
362
+ }
363
+ } else {
364
+ log.error(`No metadata found for "${basename(filePath)}". Pull the record first with "dbo pull".`);
365
+ process.exit(1);
366
+ }
367
+ }
302
368
  }
303
369
 
304
370
  // Toe-stepping check for single-file push
@@ -311,6 +377,41 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
311
377
  }
312
378
  }
313
379
 
380
+ // ── Script hooks ────────────────────────────────────────────────────
381
+ if (options.scripts !== false) {
382
+ const scriptsConfig = await loadAndMergeScripts();
383
+ if (scriptsConfig) {
384
+ const relPath = relative(process.cwd(), filePath).replace(/\\/g, '/');
385
+ const entityType = meta._entity || '';
386
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
387
+ const cfg = await loadConfig();
388
+ const app = await loadAppConfig();
389
+ const env = buildHookEnv(relPath, entityType, { ...app, domain: cfg.domain });
390
+
391
+ // Build phase
392
+ if (options.build !== false && (hooks.prebuild !== undefined || hooks.build !== undefined || hooks.postbuild !== undefined)) {
393
+ log.dim(` Running build hooks for ${basename(filePath)}...`);
394
+ const buildOk = await runBuildLifecycle(hooks, env, process.cwd());
395
+ if (!buildOk) {
396
+ log.error(`Build hook failed for "${basename(filePath)}" — aborting push`);
397
+ process.exit(1);
398
+ }
399
+ }
400
+
401
+ // Push phase
402
+ const pushResult = await runPushLifecycle(hooks, env, process.cwd());
403
+ if (pushResult.failed) {
404
+ log.error(`Push hook failed for "${basename(filePath)}" — aborting`);
405
+ process.exit(1);
406
+ }
407
+ if (!pushResult.runDefault) {
408
+ log.dim(` Skipped default push for "${basename(filePath)}" (custom push hook handled it)`);
409
+ return;
410
+ }
411
+ }
412
+ }
413
+ // ── End script hooks ────────────────────────────────────────────────
414
+
314
415
  await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
315
416
  }
316
417
  /**
@@ -328,13 +429,16 @@ async function ensureManifestMetadata() {
328
429
 
329
430
  // Scan the entire project for any metadata file that already references manifest.json.
330
431
  // This prevents creating duplicates when the metadata lives in an unexpected location.
432
+ // Check both @/manifest.json (root-relative) and @manifest.json (local) references,
433
+ // as well as Path: manifest.json which indicates a server record for this file.
331
434
  const ig = await loadIgnore();
332
435
  const allMeta = await findMetadataFiles(process.cwd(), ig);
333
436
  for (const metaPath of allMeta) {
334
437
  try {
335
438
  const raw = await readFile(metaPath, 'utf8');
336
439
  const parsed = JSON.parse(raw);
337
- if (parsed.Content === '@/manifest.json') return; // Already tracked
440
+ if (parsed.Content === '@/manifest.json' || parsed.Content === '@manifest.json') return;
441
+ if (parsed.Path === 'manifest.json' || parsed.Path === '/manifest.json') return;
338
442
  } catch { /* skip unreadable */ }
339
443
  }
340
444
 
@@ -373,10 +477,116 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
373
477
  // Auto-create manifest.metadata.json if manifest.json exists at root without companion metadata
374
478
  await ensureManifestMetadata();
375
479
 
480
+ // ── Auto-add: detect un-added files and create+submit them before push ──
376
481
  const ig = await loadIgnore();
482
+ const justAddedUIDs = new Set(); // Track UIDs added in this invocation — skip from push loop
483
+
484
+ const unadded = await findUnaddedFiles(dirPath, ig);
485
+ if (unadded.length > 0) {
486
+ // Filter to files that detectBinFile can auto-classify (content/media in bins)
487
+ const autoAddable = [];
488
+ for (const filePath of unadded) {
489
+ const binMeta = await detectBinFile(filePath);
490
+ if (binMeta) autoAddable.push({ filePath, ...binMeta });
491
+ }
492
+
493
+ if (autoAddable.length > 0) {
494
+ log.info(`Found ${autoAddable.length} new file(s) to add before push:`);
495
+ for (const { filePath } of autoAddable) {
496
+ log.plain(` ${relative(process.cwd(), filePath)}`);
497
+ }
498
+
499
+ const doAdd = async () => {
500
+ for (const { meta, metaPath, filePath } of autoAddable) {
501
+ try {
502
+ await submitAdd(meta, metaPath, filePath, client, options);
503
+ // After submitAdd, meta.UID is set if successful
504
+ if (meta.UID) justAddedUIDs.add(meta.UID);
505
+ } catch (err) {
506
+ log.error(`Failed to add ${relative(process.cwd(), filePath)}: ${err.message}`);
507
+ }
508
+ }
509
+ };
510
+
511
+ if (!options.yes) {
512
+ const inquirer = (await import('inquirer')).default;
513
+ const { proceed } = await inquirer.prompt([{
514
+ type: 'confirm',
515
+ name: 'proceed',
516
+ message: `Add ${autoAddable.length} file(s) to the server before pushing?`,
517
+ default: true,
518
+ }]);
519
+ if (!proceed) {
520
+ log.dim('Skipping auto-add — continuing with push');
521
+ } else {
522
+ await doAdd();
523
+ }
524
+ } else {
525
+ await doAdd();
526
+ }
527
+ if (justAddedUIDs.size > 0) log.plain('');
528
+ }
529
+ }
530
+
377
531
  const metaFiles = await findMetadataFiles(dirPath, ig);
378
532
 
379
- // Load baseline for delta detection
533
+ // ── Load scripts config early (before delta detection) ──────────────
534
+ // Build hooks must run BEFORE delta detection so compiled output files
535
+ // (SASS → CSS, Rollup → JS, etc.) are on disk when changes are detected.
536
+ let scriptsConfig = null;
537
+ let appConfigForHooks = null;
538
+ if (options.scripts !== false) {
539
+ scriptsConfig = await loadAndMergeScripts();
540
+ if (scriptsConfig) {
541
+ const cfg = await loadConfig();
542
+ const app = await loadAppConfig();
543
+ appConfigForHooks = { ...app, domain: cfg.domain };
544
+ }
545
+ }
546
+
547
+ // ── Run build phase upfront (before delta detection) ────────────────
548
+ if (scriptsConfig && options.build !== false) {
549
+ const globalPrebuild = scriptsConfig.scripts?.prebuild;
550
+ const globalBuild = scriptsConfig.scripts?.build;
551
+ const globalPostbuild = scriptsConfig.scripts?.postbuild;
552
+ const globalHasAnyBuild = globalPrebuild !== undefined || globalBuild !== undefined || globalPostbuild !== undefined;
553
+
554
+ // 1. Run global build hooks once
555
+ if (globalHasAnyBuild) {
556
+ const globalHooks = { prebuild: globalPrebuild, build: globalBuild, postbuild: globalPostbuild };
557
+ const env = buildHookEnv('', '', appConfigForHooks);
558
+ log.dim(' Running global build hooks...');
559
+ const ok = await runBuildLifecycle(globalHooks, env, process.cwd());
560
+ if (!ok) {
561
+ log.error('Global build hook failed — aborting push');
562
+ process.exit(1);
563
+ }
564
+ }
565
+
566
+ // 2. Run per-target/entity build hooks for each metadata file
567
+ // (only when the resolved hook differs from the global — avoids re-running global)
568
+ for (const metaPath of metaFiles) {
569
+ let meta;
570
+ try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch { continue; }
571
+ const relPath = relative(process.cwd(), metaPath.replace(/\.metadata\.json$/, '')).replace(/\\/g, '/');
572
+ const entityType = meta._entity || '';
573
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
574
+ const hasNonGlobalBuild = (hooks.prebuild !== undefined && hooks.prebuild !== globalPrebuild)
575
+ || (hooks.build !== undefined && hooks.build !== globalBuild)
576
+ || (hooks.postbuild !== undefined && hooks.postbuild !== globalPostbuild);
577
+ if (hasNonGlobalBuild) {
578
+ const env = buildHookEnv(relPath, entityType, appConfigForHooks);
579
+ log.dim(` Build hooks: ${relPath}`);
580
+ const ok = await runBuildLifecycle(hooks, env, process.cwd());
581
+ if (!ok) {
582
+ log.error(`Build hook failed for "${basename(metaPath)}" — aborting push`);
583
+ process.exit(1);
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ // Load baseline for delta detection (after build hooks so compiled files are on disk)
380
590
  const baseline = await loadAppJsonBaseline();
381
591
 
382
592
  if (!baseline) {
@@ -404,6 +614,12 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
404
614
  continue;
405
615
  }
406
616
 
617
+ // Skip records that were just auto-added in this invocation — they're already on the server
618
+ if (meta.UID && justAddedUIDs.has(meta.UID)) {
619
+ log.dim(` Skipped (just added): ${basename(metaPath)}`);
620
+ continue;
621
+ }
622
+
407
623
  // Compound output files: handle root + all inline children together
408
624
  // These have _entity='output' and inline children under .children
409
625
  if (meta._entity === 'output' && meta.children) {
@@ -518,7 +734,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
518
734
  const totalRecords = toPush.length + outputCompoundFiles.length + binPushItems.length;
519
735
  if (!options.ticket && totalRecords > 0) {
520
736
  const recordSummary = [
521
- ...toPush.map(r => basename(r.metaPath, '.metadata.json')),
737
+ ...toPush.map(r => { const p = parseMetaFilename(basename(r.metaPath)); return p ? p.naturalBase : basename(r.metaPath, '.metadata.json'); }),
522
738
  ...outputCompoundFiles.map(r => basename(r.metaPath, '.json')),
523
739
  ...binPushItems.map(r => `bin:${r.meta.Name}`),
524
740
  ].join(', ');
@@ -556,6 +772,19 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
556
772
  }
557
773
  for (const item of toAdd) {
558
774
  try {
775
+ // Run push hooks for this item (build hooks already ran upfront)
776
+ if (scriptsConfig) {
777
+ const relPath = relative(process.cwd(), item.metaPath.replace(/\.metadata\.json$/, '')).replace(/\\/g, '/');
778
+ const entityType = item.meta._entity || '';
779
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
780
+ if (hooks.prepush !== undefined || hooks.push !== undefined || hooks.postpush !== undefined) {
781
+ const env = buildHookEnv(relPath, entityType, appConfigForHooks);
782
+ const pushResult = await runPushLifecycle(hooks, env, process.cwd());
783
+ if (pushResult.failed) { log.error(`Push hook failed for "${basename(item.metaPath)}" — aborting`); failed++; break; }
784
+ if (!pushResult.runDefault) { succeeded++; successfulPushes.push(item); continue; }
785
+ }
786
+ }
787
+
559
788
  const success = await addFromMetadata(item.meta, item.metaPath, client, options, modifyKey);
560
789
  if (success) {
561
790
  succeeded++;
@@ -576,6 +805,19 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
576
805
  // Then process edits
577
806
  for (const item of toEdit) {
578
807
  try {
808
+ // Run push hooks for this item (build hooks already ran upfront)
809
+ if (scriptsConfig) {
810
+ const relPath = relative(process.cwd(), item.metaPath.replace(/\.metadata\.json$/, '')).replace(/\\/g, '/');
811
+ const entityType = item.meta._entity || '';
812
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
813
+ if (hooks.prepush !== undefined || hooks.push !== undefined || hooks.postpush !== undefined) {
814
+ const env = buildHookEnv(relPath, entityType, appConfigForHooks);
815
+ const pushResult = await runPushLifecycle(hooks, env, process.cwd());
816
+ if (pushResult.failed) { log.error(`Push hook failed for "${basename(item.metaPath)}" — aborting`); failed++; break; }
817
+ if (!pushResult.runDefault) { succeeded++; successfulPushes.push(item); continue; }
818
+ }
819
+ }
820
+
579
821
  const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
580
822
  if (success) {
581
823
  succeeded++;
@@ -636,6 +878,13 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
636
878
  await updateBaselineAfterPush(baseline, successfulPushes);
637
879
  }
638
880
 
881
+ // Re-tag successfully pushed files as Synced (best-effort)
882
+ for (const { meta, metaPath } of successfulPushes) {
883
+ for (const filePath of _getMetaCompanionPaths(meta, metaPath)) {
884
+ setFileTag(filePath, 'synced').catch(() => {});
885
+ }
886
+ }
887
+
639
888
  log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
640
889
  }
641
890
 
@@ -866,66 +1115,31 @@ async function addFromMetadata(meta, metaPath, client, options, modifyKey = null
866
1115
  return false;
867
1116
  }
868
1117
 
869
- // Extract UID from response and rename files to ~uid convention
1118
+ // Extract UID from response and rename metadata to ~uid convention
870
1119
  const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
871
1120
  if (addResults.length > 0) {
872
1121
  const returnedUID = addResults[0].UID;
873
1122
  const returnedLastUpdated = addResults[0]._LastUpdated;
874
1123
 
875
- if (returnedUID) {
876
- meta.UID = returnedUID;
877
-
878
- // Store numeric ID for delete operations (RowID:del<id>)
879
- const entityIdKey = entity.charAt(0).toUpperCase() + entity.slice(1) + 'ID';
880
- const returnedId = addResults[0][entityIdKey] || addResults[0]._id || addResults[0].ID;
881
- if (returnedId) meta._id = returnedId;
882
-
883
- const currentMetaBase = basename(metaPath, '.metadata.json');
1124
+ // Store numeric ID for delete operations (RowID:del<id>)
1125
+ const entityIdKey = entity.charAt(0).toUpperCase() + entity.slice(1) + 'ID';
1126
+ const returnedId = addResults[0][entityIdKey] || addResults[0]._id || addResults[0].ID;
1127
+ if (returnedId) meta._id = returnedId;
884
1128
 
885
- // Guard: don't append UID if it's already in the filename
886
- if (hasUidInFilename(currentMetaBase, returnedUID)) {
887
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
888
- log.success(`UID ${returnedUID} already in filename`);
889
- return true;
890
- }
891
-
892
- const newBase = buildUidFilename(currentMetaBase, returnedUID);
893
- const newMetaPath = join(metaDir, `${newBase}.metadata.json`);
894
-
895
- // Update @references in metadata to include ~UID for non-root references
896
- for (const col of (meta._contentColumns || [])) {
897
- const ref = meta[col];
898
- if (ref && String(ref).startsWith('@') && !String(ref).startsWith('@/')) {
899
- // Local file reference — rename it too
900
- const oldRefFile = String(ref).substring(1);
901
- const refExt = extname(oldRefFile);
902
- const refBase = basename(oldRefFile, refExt);
903
- const newRefBase = buildUidFilename(refBase, returnedUID);
904
- const newRefFile = refExt ? `${newRefBase}${refExt}` : newRefBase;
905
-
906
- const oldRefPath = join(metaDir, oldRefFile);
907
- const newRefPath = join(metaDir, newRefFile);
908
- try {
909
- await fsRename(oldRefPath, newRefPath);
910
- meta[col] = `@${newRefFile}`;
911
- } catch { /* content file may be root-relative */ }
912
- }
913
- }
1129
+ // Write _LastUpdated back to meta so baseline gets it
1130
+ if (returnedLastUpdated) meta._LastUpdated = returnedLastUpdated;
914
1131
 
915
- // Rename old metadata file, then write updated content
916
- if (metaPath !== newMetaPath) {
917
- try { await fsRename(metaPath, newMetaPath); } catch { /* ignore if same */ }
918
- }
919
- await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
1132
+ if (returnedUID) {
1133
+ meta.UID = returnedUID;
920
1134
 
921
- // Set timestamps from server
922
1135
  const config = await loadConfig();
923
1136
  const serverTz = config.ServerTimezone;
924
- if (serverTz && returnedLastUpdated) {
925
- try {
926
- await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
927
- } catch { /* non-critical */ }
928
- }
1137
+
1138
+ // Rename metadata file to ~UID convention; companions keep natural names
1139
+ const renameResult = await renameToUidConvention(meta, metaPath, returnedUID, returnedLastUpdated, serverTz);
1140
+
1141
+ // Propagate updated meta back (renameToUidConvention creates a new object)
1142
+ Object.assign(meta, renameResult.updatedMeta);
929
1143
 
930
1144
  log.success(`Added: ${basename(metaPath)} → UID ${returnedUID}`);
931
1145
  }
@@ -1031,7 +1245,8 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1031
1245
  }
1032
1246
 
1033
1247
  const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)${isMediaUpload ? ' + media file' : ''}` : `${dataExprs.length} field(s)`;
1034
- log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
1248
+ const pushDisplayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
1249
+ log.info(`Pushing ${pushDisplayName} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
1035
1250
 
1036
1251
  // Apply stored ticket if no --ticket flag
1037
1252
  const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
@@ -1259,7 +1474,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
1259
1474
  if (ENTITY_DIR_NAMES.has(entity)) return;
1260
1475
 
1261
1476
  const metaDir = dirname(metaPath);
1262
- const metaBase = basename(metaPath, '.metadata.json');
1477
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
1263
1478
 
1264
1479
  // Find the content file referenced by @filename
1265
1480
  const contentCols = meta._contentColumns || [];
@@ -1469,6 +1684,11 @@ async function _submitOutputEntity(entity, physicalEntity, changedColumns, metaD
1469
1684
  const strValue = (val === null || val === undefined) ? '' : String(val);
1470
1685
  if (isReference(strValue)) {
1471
1686
  const refPath = resolveReferencePath(strValue, metaDir);
1687
+ // Skip missing companion files (e.g. empty CustomSQL not extracted by clone)
1688
+ try { await access(refPath); } catch {
1689
+ log.dim(` Skipping ${col} — companion file not found: ${basename(refPath)}`);
1690
+ continue;
1691
+ }
1472
1692
  dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}@${refPath}`);
1473
1693
  } else {
1474
1694
  dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}=${strValue}`);
@@ -5,8 +5,10 @@ import { log } from '../lib/logger.js';
5
5
  import { formatError } from '../lib/formatter.js';
6
6
  import { addDeleteEntry, removeAppJsonReference } from '../lib/config.js';
7
7
  import { findMetadataFiles } from '../lib/diff.js';
8
+ import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
8
9
  import { loadStructureFile, findBinByPath, findChildBins, BINS_DIR } from '../lib/structure.js';
9
10
  import { runPendingMigrations } from '../lib/migrations.js';
11
+ import { removeDeployEntry } from '../lib/deploy-config.js';
10
12
 
11
13
  export const rmCommand = new Command('rm')
12
14
  .description('Remove a file or directory locally and stage server deletions for the next dbo push')
@@ -41,7 +43,7 @@ export const rmCommand = new Command('rm')
41
43
  * Resolve a file path to its metadata.json path.
42
44
  */
43
45
  function resolveMetaPath(filePath) {
44
- if (filePath.endsWith('.metadata.json')) {
46
+ if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
45
47
  return filePath;
46
48
  }
47
49
  const dir = dirname(filePath);
@@ -100,7 +102,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
100
102
  localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
101
103
  }
102
104
 
103
- const displayName = basename(metaPath, '.metadata.json');
105
+ const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
104
106
 
105
107
  // Prompt if needed
106
108
  if (!skipPrompt && !options.force) {
@@ -120,6 +122,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
120
122
  // Stage deletion (include metaPath for Trash workflow in push.js)
121
123
  const expression = `RowID:del${rowId};entity:${entity}=true`;
122
124
  await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
125
+ await removeDeployEntry(uid);
123
126
  log.success(` Staged: ${displayName} → ${expression}`);
124
127
 
125
128
  // Remove from app.json
@@ -156,14 +159,26 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
156
159
  * Remove a single file (entry point for non-directory rm).
157
160
  */
158
161
  async function rmFile(filePath, options) {
159
- const metaPath = resolveMetaPath(filePath);
162
+ let metaPath = resolveMetaPath(filePath);
160
163
 
161
164
  let meta;
162
165
  try {
163
166
  meta = JSON.parse(await readFile(metaPath, 'utf8'));
164
167
  } catch {
165
- log.error(`No metadata found at "${metaPath}". Cannot determine record to delete.`);
166
- process.exit(1);
168
+ // Direct metadata path not found search by @reference
169
+ const found = await findMetadataForCompanion(filePath);
170
+ if (found) {
171
+ metaPath = found;
172
+ try {
173
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
174
+ } catch {
175
+ log.error(`Could not read metadata at "${metaPath}".`);
176
+ process.exit(1);
177
+ }
178
+ } else {
179
+ log.error(`No metadata found for "${basename(filePath)}". Cannot determine record to delete.`);
180
+ process.exit(1);
181
+ }
167
182
  }
168
183
 
169
184
  const entity = meta._entity;
@@ -192,7 +207,7 @@ async function rmFile(filePath, options) {
192
207
  localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
193
208
  }
194
209
 
195
- const displayName = basename(metaPath, '.metadata.json');
210
+ const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
196
211
  log.info(`Removing "${displayName}" (${entity}:${uid || rowId})`);
197
212
  for (const f of localFiles) {
198
213
  log.dim(` ${f}`);