@dboio/cli 0.11.4 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +126 -3
  2. package/bin/dbo.js +4 -0
  3. package/package.json +1 -1
  4. package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
  5. package/plugins/claude/dbo/commands/dbo.md +65 -244
  6. package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
  7. package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
  8. package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
  9. package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
  10. package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
  11. package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
  12. package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
  13. package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
  14. package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
  15. package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
  16. package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
  17. package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
  18. package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
  19. package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
  20. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
  21. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
  22. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
  23. package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
  24. package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
  25. package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
  26. package/plugins/claude/dbo/docs/dbo-cli-readme.md +2222 -0
  27. package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
  28. package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
  29. package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
  30. package/plugins/claude/dbo/skills/cli/SKILL.md +62 -246
  31. package/src/commands/add.js +366 -62
  32. package/src/commands/build.js +102 -0
  33. package/src/commands/clone.js +602 -139
  34. package/src/commands/diff.js +4 -0
  35. package/src/commands/init.js +16 -2
  36. package/src/commands/input.js +3 -1
  37. package/src/commands/mv.js +12 -4
  38. package/src/commands/push.js +265 -70
  39. package/src/commands/rm.js +16 -3
  40. package/src/commands/run.js +81 -0
  41. package/src/lib/config.js +39 -0
  42. package/src/lib/delta.js +7 -1
  43. package/src/lib/diff.js +24 -2
  44. package/src/lib/filenames.js +120 -41
  45. package/src/lib/ignore.js +6 -0
  46. package/src/lib/input-parser.js +13 -4
  47. package/src/lib/scripts.js +232 -0
  48. package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
@@ -14,6 +14,7 @@ import {
14
14
  } from '../lib/diff.js';
15
15
  import { fetchServerRecordsBatch } from '../lib/toe-stepping.js';
16
16
  import { findBaselineEntry } from '../lib/delta.js';
17
+ import { findMetadataForCompanion } from '../lib/filenames.js';
17
18
  import { runPendingMigrations } from '../lib/migrations.js';
18
19
 
19
20
  export const diffCommand = new Command('diff')
@@ -239,6 +240,9 @@ async function resolveTargetToMetaFiles(targetPath) {
239
240
  await stat(mediaMetaPath);
240
241
  return [mediaMetaPath];
241
242
  } catch {
243
+ // Fallback: scan sibling metadata files for @reference match
244
+ const found = await findMetadataForCompanion(targetPath);
245
+ if (found) return [found];
242
246
  log.warn(`No metadata found for "${targetPath}"`);
243
247
  return [];
244
248
  }
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { mkdir } from 'fs/promises';
2
+ import { mkdir, writeFile, access } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
5
5
  import { installOrUpdateClaudeCommands } from './install.js';
@@ -102,7 +102,7 @@ export const initCommand = new Command('init')
102
102
  }
103
103
 
104
104
  // Ensure sensitive files are gitignored
105
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r']);
105
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/scripts.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r']);
106
106
 
107
107
  const createdIgnore = await createDboignore();
108
108
  if (createdIgnore) log.dim(' Created .dboignore');
@@ -114,6 +114,20 @@ export const initCommand = new Command('init')
114
114
  }
115
115
  log.dim(' Created .claude/ directory structure');
116
116
 
117
+ // Create empty scripts.json and scripts.local.json if they don't exist
118
+ const emptyScripts = JSON.stringify({ scripts: {}, targets: {}, entities: {} }, null, 2) + '\n';
119
+ const dboDir = join(process.cwd(), '.dbo');
120
+ const scriptsPath = join(dboDir, 'scripts.json');
121
+ const scriptsLocalPath = join(dboDir, 'scripts.local.json');
122
+ try { await access(scriptsPath); } catch {
123
+ await writeFile(scriptsPath, emptyScripts);
124
+ log.dim(' Created .dbo/scripts.json');
125
+ }
126
+ try { await access(scriptsLocalPath); } catch {
127
+ await writeFile(scriptsLocalPath, emptyScripts);
128
+ log.dim(' Created .dbo/scripts.local.json');
129
+ }
130
+
117
131
  log.success(`Initialized .dbo/ for ${domain}`);
118
132
 
119
133
  // Authenticate early so the session is ready for subsequent operations
@@ -60,8 +60,10 @@ export const inputCommand = new Command('input')
60
60
  if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
61
61
 
62
62
  // Check if data expressions include AppID; if not and config has one, prompt
63
+ // Skip AppID prompt for delete-only submissions — deletes don't need it
63
64
  const allDataText = options.data.join(' ');
64
- const hasAppId = /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
65
+ const isDeleteOnly = options.data.every(d => /RowID:del\d+/.test(d));
66
+ const hasAppId = isDeleteOnly || /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
65
67
  if (!hasAppId) {
66
68
  const appConfig = await loadAppConfig();
67
69
  if (appConfig.AppID) {
@@ -13,6 +13,7 @@ import {
13
13
  BINS_DIR
14
14
  } from '../lib/structure.js';
15
15
  import { findMetadataFiles } from '../lib/diff.js';
16
+ import { findMetadataForCompanion } from '../lib/filenames.js';
16
17
  import { runPendingMigrations } from '../lib/migrations.js';
17
18
 
18
19
  export const mvCommand = new Command('mv')
@@ -507,14 +508,21 @@ async function checkNameConflict(fileName, targetDir, options) {
507
508
  */
508
509
  async function mvFile(sourceFile, targetBinId, structure, options) {
509
510
  // Resolve metadata
510
- const metaPath = resolveMetaPath(sourceFile);
511
+ let metaPath = resolveMetaPath(sourceFile);
511
512
  let meta;
512
513
  try {
513
514
  meta = JSON.parse(await readFile(metaPath, 'utf8'));
514
515
  } catch {
515
- log.error(`No metadata found for "${sourceFile}"`);
516
- log.dim(' Use "dbo content pull" or "dbo output --save" to create metadata files.');
517
- process.exit(1);
516
+ const found = await findMetadataForCompanion(sourceFile);
517
+ if (found) {
518
+ metaPath = found;
519
+ try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch {}
520
+ }
521
+ if (!meta) {
522
+ log.error(`No metadata found for "${basename(sourceFile)}"`);
523
+ log.dim(' Use "dbo pull" to create metadata files.');
524
+ process.exit(1);
525
+ }
518
526
  }
519
527
 
520
528
  const entity = meta._entity;
@@ -6,12 +6,13 @@ 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, 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';
@@ -19,6 +20,7 @@ import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '..
19
20
  import { ensureTrashIcon } from '../lib/folder-icon.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';
22
24
 
23
25
  /**
24
26
  * Resolve an @reference file path to an absolute filesystem path.
@@ -42,9 +44,16 @@ function isToeStepping(options) {
42
44
 
43
45
  import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
44
46
 
47
+ async function loadAndMergeScripts() {
48
+ const base = await loadScripts();
49
+ const local = await loadScriptsLocal();
50
+ if (!base && !local) return null;
51
+ return mergeScriptsConfig(base, local);
52
+ }
53
+
45
54
  export const pushCommand = new Command('push')
46
55
  .description('Push local files back to DBO.io using metadata from pull')
47
- .argument('<path>', 'File or directory to push')
56
+ .argument('[path]', 'File or directory to push (default: current directory)')
48
57
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
49
58
  .option('--ticket <id>', 'Override ticket ID')
50
59
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
@@ -58,7 +67,9 @@ export const pushCommand = new Command('push')
58
67
  .option('-v, --verbose', 'Show HTTP request details')
59
68
  .option('--domain <host>', 'Override domain')
60
69
  .option('--no-migrate', 'Skip pending migrations for this invocation')
61
- .action(async (targetPath, options) => {
70
+ .option('--no-scripts', 'Bypass all script hooks; run default push pipeline unconditionally')
71
+ .option('--no-build', 'Skip the build phase (prebuild/build/postbuild); run push phase only')
72
+ .action(async (targetPath = ".", options) => {
62
73
  try {
63
74
  await runPendingMigrations(options);
64
75
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
@@ -169,18 +180,29 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
169
180
  const body = await buildInputBody([entry.expression], extraParams);
170
181
 
171
182
  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)');
183
+ let result = await client.postUrlEncoded('/api/input/submit', body);
184
+
185
+ // Retry with prompted params if needed (ticket, repo mismatch, user)
186
+ if (!result.successful) {
187
+ const retryResult = await checkSubmitErrors(result, { rowUid: entry.UID });
188
+ if (retryResult) {
189
+ if (retryResult.skipRecord) { remaining.push(entry); continue; }
190
+ if (retryResult.skipAll) break;
191
+ if (retryResult.ticketExpressions?.length > 0) {
192
+ // Re-build body with ticket expressions added to the delete expression
193
+ const allExprs = [entry.expression, ...retryResult.ticketExpressions];
194
+ const retryParams = { ...extraParams, ...(retryResult.retryParams || retryResult) };
195
+ const retryBody = await buildInputBody(allExprs, retryParams);
196
+ result = await client.postUrlEncoded('/api/input/submit', retryBody);
197
+ } else {
198
+ const retryParams = { ...extraParams, ...(retryResult.retryParams || retryResult) };
199
+ const retryBody = await buildInputBody([entry.expression], retryParams);
200
+ result = await client.postUrlEncoded('/api/input/submit', retryBody);
201
+ }
202
+ }
203
+ }
204
+
205
+ if (result.successful) {
184
206
  log.success(` Deleted "${entry.name}" from server`);
185
207
  deletedUids.push(entry.UID);
186
208
  } else {
@@ -297,8 +319,35 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
297
319
  try {
298
320
  meta = JSON.parse(await readFile(metaPath, 'utf8'));
299
321
  } 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);
322
+ // Direct metadata path not found search by @reference
323
+ const found = await findMetadataForCompanion(filePath);
324
+ if (found) {
325
+ metaPath = found;
326
+ try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch {}
327
+ }
328
+ if (!meta) {
329
+ // Try auto-detecting as a bin content/media file and add it first
330
+ const binMeta = await detectBinFile(filePath);
331
+ if (binMeta) {
332
+ log.info(`No metadata found — auto-adding "${basename(filePath)}" first`);
333
+ try {
334
+ await submitAdd(binMeta.meta, binMeta.metaPath, filePath, client, options);
335
+ // After successful add, re-read the metadata (now has UID)
336
+ metaPath = binMeta.metaPath;
337
+ // The metadata file may have been renamed with ~UID, so scan for it
338
+ const updatedMeta = await findMetadataForCompanion(filePath);
339
+ if (updatedMeta) metaPath = updatedMeta;
340
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
341
+ log.info(`Successfully added — now pushing updates`);
342
+ } catch (err) {
343
+ log.error(`Auto-add failed for "${basename(filePath)}": ${err.message}`);
344
+ process.exit(1);
345
+ }
346
+ } else {
347
+ log.error(`No metadata found for "${basename(filePath)}". Pull the record first with "dbo pull".`);
348
+ process.exit(1);
349
+ }
350
+ }
302
351
  }
303
352
 
304
353
  // Toe-stepping check for single-file push
@@ -311,6 +360,41 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
311
360
  }
312
361
  }
313
362
 
363
+ // ── Script hooks ────────────────────────────────────────────────────
364
+ if (options.scripts !== false) {
365
+ const scriptsConfig = await loadAndMergeScripts();
366
+ if (scriptsConfig) {
367
+ const relPath = relative(process.cwd(), filePath).replace(/\\/g, '/');
368
+ const entityType = meta._entity || '';
369
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
370
+ const cfg = await loadConfig();
371
+ const app = await loadAppConfig();
372
+ const env = buildHookEnv(relPath, entityType, { ...app, domain: cfg.domain });
373
+
374
+ // Build phase
375
+ if (options.build !== false && (hooks.prebuild !== undefined || hooks.build !== undefined || hooks.postbuild !== undefined)) {
376
+ log.dim(` Running build hooks for ${basename(filePath)}...`);
377
+ const buildOk = await runBuildLifecycle(hooks, env, process.cwd());
378
+ if (!buildOk) {
379
+ log.error(`Build hook failed for "${basename(filePath)}" — aborting push`);
380
+ process.exit(1);
381
+ }
382
+ }
383
+
384
+ // Push phase
385
+ const pushResult = await runPushLifecycle(hooks, env, process.cwd());
386
+ if (pushResult.failed) {
387
+ log.error(`Push hook failed for "${basename(filePath)}" — aborting`);
388
+ process.exit(1);
389
+ }
390
+ if (!pushResult.runDefault) {
391
+ log.dim(` Skipped default push for "${basename(filePath)}" (custom push hook handled it)`);
392
+ return;
393
+ }
394
+ }
395
+ }
396
+ // ── End script hooks ────────────────────────────────────────────────
397
+
314
398
  await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
315
399
  }
316
400
  /**
@@ -328,13 +412,16 @@ async function ensureManifestMetadata() {
328
412
 
329
413
  // Scan the entire project for any metadata file that already references manifest.json.
330
414
  // This prevents creating duplicates when the metadata lives in an unexpected location.
415
+ // Check both @/manifest.json (root-relative) and @manifest.json (local) references,
416
+ // as well as Path: manifest.json which indicates a server record for this file.
331
417
  const ig = await loadIgnore();
332
418
  const allMeta = await findMetadataFiles(process.cwd(), ig);
333
419
  for (const metaPath of allMeta) {
334
420
  try {
335
421
  const raw = await readFile(metaPath, 'utf8');
336
422
  const parsed = JSON.parse(raw);
337
- if (parsed.Content === '@/manifest.json') return; // Already tracked
423
+ if (parsed.Content === '@/manifest.json' || parsed.Content === '@manifest.json') return;
424
+ if (parsed.Path === 'manifest.json' || parsed.Path === '/manifest.json') return;
338
425
  } catch { /* skip unreadable */ }
339
426
  }
340
427
 
@@ -373,10 +460,116 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
373
460
  // Auto-create manifest.metadata.json if manifest.json exists at root without companion metadata
374
461
  await ensureManifestMetadata();
375
462
 
463
+ // ── Auto-add: detect un-added files and create+submit them before push ──
376
464
  const ig = await loadIgnore();
465
+ const justAddedUIDs = new Set(); // Track UIDs added in this invocation — skip from push loop
466
+
467
+ const unadded = await findUnaddedFiles(dirPath, ig);
468
+ if (unadded.length > 0) {
469
+ // Filter to files that detectBinFile can auto-classify (content/media in bins)
470
+ const autoAddable = [];
471
+ for (const filePath of unadded) {
472
+ const binMeta = await detectBinFile(filePath);
473
+ if (binMeta) autoAddable.push({ filePath, ...binMeta });
474
+ }
475
+
476
+ if (autoAddable.length > 0) {
477
+ log.info(`Found ${autoAddable.length} new file(s) to add before push:`);
478
+ for (const { filePath } of autoAddable) {
479
+ log.plain(` ${relative(process.cwd(), filePath)}`);
480
+ }
481
+
482
+ const doAdd = async () => {
483
+ for (const { meta, metaPath, filePath } of autoAddable) {
484
+ try {
485
+ await submitAdd(meta, metaPath, filePath, client, options);
486
+ // After submitAdd, meta.UID is set if successful
487
+ if (meta.UID) justAddedUIDs.add(meta.UID);
488
+ } catch (err) {
489
+ log.error(`Failed to add ${relative(process.cwd(), filePath)}: ${err.message}`);
490
+ }
491
+ }
492
+ };
493
+
494
+ if (!options.yes) {
495
+ const inquirer = (await import('inquirer')).default;
496
+ const { proceed } = await inquirer.prompt([{
497
+ type: 'confirm',
498
+ name: 'proceed',
499
+ message: `Add ${autoAddable.length} file(s) to the server before pushing?`,
500
+ default: true,
501
+ }]);
502
+ if (!proceed) {
503
+ log.dim('Skipping auto-add — continuing with push');
504
+ } else {
505
+ await doAdd();
506
+ }
507
+ } else {
508
+ await doAdd();
509
+ }
510
+ if (justAddedUIDs.size > 0) log.plain('');
511
+ }
512
+ }
513
+
377
514
  const metaFiles = await findMetadataFiles(dirPath, ig);
378
515
 
379
- // Load baseline for delta detection
516
+ // ── Load scripts config early (before delta detection) ──────────────
517
+ // Build hooks must run BEFORE delta detection so compiled output files
518
+ // (SASS → CSS, Rollup → JS, etc.) are on disk when changes are detected.
519
+ let scriptsConfig = null;
520
+ let appConfigForHooks = null;
521
+ if (options.scripts !== false) {
522
+ scriptsConfig = await loadAndMergeScripts();
523
+ if (scriptsConfig) {
524
+ const cfg = await loadConfig();
525
+ const app = await loadAppConfig();
526
+ appConfigForHooks = { ...app, domain: cfg.domain };
527
+ }
528
+ }
529
+
530
+ // ── Run build phase upfront (before delta detection) ────────────────
531
+ if (scriptsConfig && options.build !== false) {
532
+ const globalPrebuild = scriptsConfig.scripts?.prebuild;
533
+ const globalBuild = scriptsConfig.scripts?.build;
534
+ const globalPostbuild = scriptsConfig.scripts?.postbuild;
535
+ const globalHasAnyBuild = globalPrebuild !== undefined || globalBuild !== undefined || globalPostbuild !== undefined;
536
+
537
+ // 1. Run global build hooks once
538
+ if (globalHasAnyBuild) {
539
+ const globalHooks = { prebuild: globalPrebuild, build: globalBuild, postbuild: globalPostbuild };
540
+ const env = buildHookEnv('', '', appConfigForHooks);
541
+ log.dim(' Running global build hooks...');
542
+ const ok = await runBuildLifecycle(globalHooks, env, process.cwd());
543
+ if (!ok) {
544
+ log.error('Global build hook failed — aborting push');
545
+ process.exit(1);
546
+ }
547
+ }
548
+
549
+ // 2. Run per-target/entity build hooks for each metadata file
550
+ // (only when the resolved hook differs from the global — avoids re-running global)
551
+ for (const metaPath of metaFiles) {
552
+ let meta;
553
+ try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch { continue; }
554
+ const relPath = relative(process.cwd(), metaPath.replace(/\.metadata\.json$/, '')).replace(/\\/g, '/');
555
+ const entityType = meta._entity || '';
556
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
557
+ const hasNonGlobalBuild = (hooks.prebuild !== undefined && hooks.prebuild !== globalPrebuild)
558
+ || (hooks.build !== undefined && hooks.build !== globalBuild)
559
+ || (hooks.postbuild !== undefined && hooks.postbuild !== globalPostbuild);
560
+ if (hasNonGlobalBuild) {
561
+ const env = buildHookEnv(relPath, entityType, appConfigForHooks);
562
+ log.dim(` Build hooks: ${relPath}`);
563
+ const ok = await runBuildLifecycle(hooks, env, process.cwd());
564
+ if (!ok) {
565
+ log.error(`Build hook failed for "${basename(metaPath)}" — aborting push`);
566
+ process.exit(1);
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ // Load baseline for delta detection (after build hooks so compiled files are on disk)
380
573
  const baseline = await loadAppJsonBaseline();
381
574
 
382
575
  if (!baseline) {
@@ -404,6 +597,12 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
404
597
  continue;
405
598
  }
406
599
 
600
+ // Skip records that were just auto-added in this invocation — they're already on the server
601
+ if (meta.UID && justAddedUIDs.has(meta.UID)) {
602
+ log.dim(` Skipped (just added): ${basename(metaPath)}`);
603
+ continue;
604
+ }
605
+
407
606
  // Compound output files: handle root + all inline children together
408
607
  // These have _entity='output' and inline children under .children
409
608
  if (meta._entity === 'output' && meta.children) {
@@ -556,6 +755,19 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
556
755
  }
557
756
  for (const item of toAdd) {
558
757
  try {
758
+ // Run push hooks for this item (build hooks already ran upfront)
759
+ if (scriptsConfig) {
760
+ const relPath = relative(process.cwd(), item.metaPath.replace(/\.metadata\.json$/, '')).replace(/\\/g, '/');
761
+ const entityType = item.meta._entity || '';
762
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
763
+ if (hooks.prepush !== undefined || hooks.push !== undefined || hooks.postpush !== undefined) {
764
+ const env = buildHookEnv(relPath, entityType, appConfigForHooks);
765
+ const pushResult = await runPushLifecycle(hooks, env, process.cwd());
766
+ if (pushResult.failed) { log.error(`Push hook failed for "${basename(item.metaPath)}" — aborting`); failed++; break; }
767
+ if (!pushResult.runDefault) { succeeded++; successfulPushes.push(item); continue; }
768
+ }
769
+ }
770
+
559
771
  const success = await addFromMetadata(item.meta, item.metaPath, client, options, modifyKey);
560
772
  if (success) {
561
773
  succeeded++;
@@ -576,6 +788,19 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
576
788
  // Then process edits
577
789
  for (const item of toEdit) {
578
790
  try {
791
+ // Run push hooks for this item (build hooks already ran upfront)
792
+ if (scriptsConfig) {
793
+ const relPath = relative(process.cwd(), item.metaPath.replace(/\.metadata\.json$/, '')).replace(/\\/g, '/');
794
+ const entityType = item.meta._entity || '';
795
+ const hooks = resolveHooks(relPath, entityType, scriptsConfig);
796
+ if (hooks.prepush !== undefined || hooks.push !== undefined || hooks.postpush !== undefined) {
797
+ const env = buildHookEnv(relPath, entityType, appConfigForHooks);
798
+ const pushResult = await runPushLifecycle(hooks, env, process.cwd());
799
+ if (pushResult.failed) { log.error(`Push hook failed for "${basename(item.metaPath)}" — aborting`); failed++; break; }
800
+ if (!pushResult.runDefault) { succeeded++; successfulPushes.push(item); continue; }
801
+ }
802
+ }
803
+
579
804
  const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
580
805
  if (success) {
581
806
  succeeded++;
@@ -866,66 +1091,31 @@ async function addFromMetadata(meta, metaPath, client, options, modifyKey = null
866
1091
  return false;
867
1092
  }
868
1093
 
869
- // Extract UID from response and rename files to ~uid convention
1094
+ // Extract UID from response and rename metadata to ~uid convention
870
1095
  const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
871
1096
  if (addResults.length > 0) {
872
1097
  const returnedUID = addResults[0].UID;
873
1098
  const returnedLastUpdated = addResults[0]._LastUpdated;
874
1099
 
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');
884
-
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
- }
1100
+ // Store numeric ID for delete operations (RowID:del<id>)
1101
+ const entityIdKey = entity.charAt(0).toUpperCase() + entity.slice(1) + 'ID';
1102
+ const returnedId = addResults[0][entityIdKey] || addResults[0]._id || addResults[0].ID;
1103
+ if (returnedId) meta._id = returnedId;
891
1104
 
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
- }
1105
+ // Write _LastUpdated back to meta so baseline gets it
1106
+ if (returnedLastUpdated) meta._LastUpdated = returnedLastUpdated;
914
1107
 
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');
1108
+ if (returnedUID) {
1109
+ meta.UID = returnedUID;
920
1110
 
921
- // Set timestamps from server
922
1111
  const config = await loadConfig();
923
1112
  const serverTz = config.ServerTimezone;
924
- if (serverTz && returnedLastUpdated) {
925
- try {
926
- await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
927
- } catch { /* non-critical */ }
928
- }
1113
+
1114
+ // Rename metadata file to ~UID convention; companions keep natural names
1115
+ const renameResult = await renameToUidConvention(meta, metaPath, returnedUID, returnedLastUpdated, serverTz);
1116
+
1117
+ // Propagate updated meta back (renameToUidConvention creates a new object)
1118
+ Object.assign(meta, renameResult.updatedMeta);
929
1119
 
930
1120
  log.success(`Added: ${basename(metaPath)} → UID ${returnedUID}`);
931
1121
  }
@@ -1469,6 +1659,11 @@ async function _submitOutputEntity(entity, physicalEntity, changedColumns, metaD
1469
1659
  const strValue = (val === null || val === undefined) ? '' : String(val);
1470
1660
  if (isReference(strValue)) {
1471
1661
  const refPath = resolveReferencePath(strValue, metaDir);
1662
+ // Skip missing companion files (e.g. empty CustomSQL not extracted by clone)
1663
+ try { await access(refPath); } catch {
1664
+ log.dim(` Skipping ${col} — companion file not found: ${basename(refPath)}`);
1665
+ continue;
1666
+ }
1472
1667
  dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}@${refPath}`);
1473
1668
  } else {
1474
1669
  dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}=${strValue}`);
@@ -5,6 +5,7 @@ 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 { 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';
10
11
 
@@ -156,14 +157,26 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
156
157
  * Remove a single file (entry point for non-directory rm).
157
158
  */
158
159
  async function rmFile(filePath, options) {
159
- const metaPath = resolveMetaPath(filePath);
160
+ let metaPath = resolveMetaPath(filePath);
160
161
 
161
162
  let meta;
162
163
  try {
163
164
  meta = JSON.parse(await readFile(metaPath, 'utf8'));
164
165
  } catch {
165
- log.error(`No metadata found at "${metaPath}". Cannot determine record to delete.`);
166
- process.exit(1);
166
+ // Direct metadata path not found search by @reference
167
+ const found = await findMetadataForCompanion(filePath);
168
+ if (found) {
169
+ metaPath = found;
170
+ try {
171
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
172
+ } catch {
173
+ log.error(`Could not read metadata at "${metaPath}".`);
174
+ process.exit(1);
175
+ }
176
+ } else {
177
+ log.error(`No metadata found for "${basename(filePath)}". Cannot determine record to delete.`);
178
+ process.exit(1);
179
+ }
167
180
  }
168
181
 
169
182
  const entity = meta._entity;