@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
@@ -0,0 +1,81 @@
1
+ // src/commands/run.js
2
+ import { Command } from 'commander';
3
+ import { loadScripts, loadScriptsLocal, loadConfig, loadAppConfig } from '../lib/config.js';
4
+ import { mergeScriptsConfig, buildHookEnv, runHook } from '../lib/scripts.js';
5
+ import { log } from '../lib/logger.js';
6
+
7
+ export const runCommand = new Command('run')
8
+ .description('Run a named script from .dbo/scripts.json (like npm run)')
9
+ .argument('[script-name]', 'Name of the script to run (omit to list all scripts)')
10
+ .action(async (scriptName, options) => {
11
+ try {
12
+ const base = await loadScripts();
13
+ const local = await loadScriptsLocal();
14
+
15
+ if (!base && !local) {
16
+ log.error('No .dbo/scripts.json found');
17
+ process.exit(1);
18
+ }
19
+
20
+ const config = mergeScriptsConfig(base, local);
21
+ const scripts = config.scripts || {};
22
+
23
+ if (!scriptName) {
24
+ // List all script names
25
+ const allNames = Object.keys(scripts);
26
+ if (allNames.length === 0) {
27
+ log.info('No scripts defined in .dbo/scripts.json');
28
+ return;
29
+ }
30
+ log.info('Available scripts:');
31
+ for (const name of allNames) {
32
+ const value = scripts[name];
33
+ const preview = Array.isArray(value) ? value[0] : (value === false ? '(skip)' : String(value));
34
+ log.plain(` ${name}: ${preview}`);
35
+ }
36
+ return;
37
+ }
38
+
39
+ if (!(scriptName in scripts)) {
40
+ log.error(`Script "${scriptName}" not found in .dbo/scripts.json`);
41
+ process.exit(1);
42
+ }
43
+
44
+ const cfg = await loadConfig();
45
+ const app = await loadAppConfig();
46
+ const appConfig = { ...app, domain: cfg.domain };
47
+ const env = buildHookEnv('', scriptName, appConfig);
48
+
49
+ // Pre/post auto-resolution
50
+ const preKey = `pre${scriptName}`;
51
+ const postKey = `post${scriptName}`;
52
+
53
+ if (preKey in scripts) {
54
+ log.dim(` Running ${preKey}...`);
55
+ const result = await runHook(scripts[preKey], env, { cwd: process.cwd() });
56
+ if (!result.skipped && result.exitCode !== 0 && result.exitCode !== null) {
57
+ log.error(`Script "${preKey}" failed with exit code ${result.exitCode}`);
58
+ process.exit(result.exitCode);
59
+ }
60
+ }
61
+
62
+ log.dim(` Running ${scriptName}...`);
63
+ const result = await runHook(scripts[scriptName], env, { cwd: process.cwd() });
64
+ if (!result.skipped && result.exitCode !== 0 && result.exitCode !== null) {
65
+ log.error(`Script "${scriptName}" failed with exit code ${result.exitCode}`);
66
+ process.exit(result.exitCode);
67
+ }
68
+
69
+ if (postKey in scripts) {
70
+ log.dim(` Running ${postKey}...`);
71
+ const postResult = await runHook(scripts[postKey], env, { cwd: process.cwd() });
72
+ if (!postResult.skipped && postResult.exitCode !== 0 && postResult.exitCode !== null) {
73
+ log.error(`Script "${postKey}" failed with exit code ${postResult.exitCode}`);
74
+ process.exit(postResult.exitCode);
75
+ }
76
+ }
77
+ } catch (err) {
78
+ log.error(err.message);
79
+ process.exit(1);
80
+ }
81
+ });
@@ -0,0 +1,65 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { tagProjectFiles } from '../lib/tagging.js';
4
+ import { loadTagConfig, saveTagConfig } from '../lib/config.js';
5
+ import { loadIgnore } from '../lib/ignore.js';
6
+ import { join } from 'path';
7
+
8
+ export const tagCommand = new Command('tag')
9
+ .description('Apply sync-status tags (Finder color tags / gio emblems) to project files')
10
+ .argument('[path]', 'File or directory to tag (defaults to entire project)')
11
+ .option('--clear', 'Remove all dbo:* tags from companion files')
12
+ .option('--status', 'Show counts of synced / modified / untracked / trashed files')
13
+ .option('--enable', 'Enable automatic tagging after clone/pull/push')
14
+ .option('--disable', 'Disable automatic tagging after clone/pull/push')
15
+ .option('--verbose', 'Log each file with its status')
16
+ .action(async (pathArg, options) => {
17
+ if (options.enable) {
18
+ await saveTagConfig(true);
19
+ console.log(chalk.green('✔ Automatic file tagging enabled'));
20
+ return;
21
+ }
22
+ if (options.disable) {
23
+ await saveTagConfig(false);
24
+ console.log(chalk.yellow('Automatic file tagging disabled'));
25
+ return;
26
+ }
27
+
28
+ // Verify project is initialized
29
+ const ig = await loadIgnore().catch(() => null);
30
+ if (!ig) {
31
+ console.error(chalk.red('Not a dbo project'));
32
+ process.exit(1);
33
+ }
34
+
35
+ const dir = pathArg ? join(process.cwd(), pathArg) : process.cwd();
36
+
37
+ if (options.clear) {
38
+ await tagProjectFiles({ clearAll: true, dir, verbose: options.verbose });
39
+ console.log(chalk.green('✔ All dbo:* tags cleared'));
40
+ return;
41
+ }
42
+
43
+ const counts = await tagProjectFiles({ verbose: options.verbose, dir });
44
+
45
+ if (counts === null) {
46
+ const { tagFiles } = await loadTagConfig();
47
+ if (!tagFiles) {
48
+ console.log(chalk.yellow('File tagging is disabled. Run `dbo tag --enable` to enable.'));
49
+ } else {
50
+ console.log(chalk.dim('File tagging is not supported on this platform.'));
51
+ }
52
+ return;
53
+ }
54
+
55
+ if (options.status || options.verbose) {
56
+ console.log(
57
+ `${chalk.green(counts.synced)} synced, ` +
58
+ `${chalk.blue(counts.modified)} modified, ` +
59
+ `${chalk.yellow(counts.untracked)} untracked, ` +
60
+ `${chalk.red(counts.trashed)} trashed`
61
+ );
62
+ } else {
63
+ console.log(chalk.green(`✔ Tags applied (${counts.synced} synced, ${counts.modified} modified, ${counts.untracked} untracked)`));
64
+ }
65
+ });
package/src/lib/config.js CHANGED
@@ -9,6 +9,8 @@ const CREDENTIALS_FILE = 'credentials.json';
9
9
  const COOKIES_FILE = 'cookies.txt';
10
10
  const SYNCHRONIZE_FILE = 'synchronize.json';
11
11
  const BASELINE_FILE = '.app_baseline.json';
12
+ const SCRIPTS_FILE = 'scripts.json';
13
+ const SCRIPTS_LOCAL_FILE = 'scripts.local.json';
12
14
 
13
15
  function dboDir() {
14
16
  return join(process.cwd(), DBO_DIR);
@@ -897,3 +899,68 @@ export async function loadExtensionDocumentationMDPlacement() {
897
899
  return cfg.ExtensionDocumentationMDPlacement || null;
898
900
  } catch { return null; }
899
901
  }
902
+
903
+ // ─── Script Hooks (.dbo/scripts.json, .dbo/scripts.local.json) ────────────
904
+
905
+ function scriptsPath() {
906
+ return join(dboDir(), SCRIPTS_FILE);
907
+ }
908
+
909
+ function scriptsLocalPath() {
910
+ return join(dboDir(), SCRIPTS_LOCAL_FILE);
911
+ }
912
+
913
+ /**
914
+ * Load .dbo/scripts.json. Returns parsed object or null if missing.
915
+ * Throws SyntaxError with clear message if JSON is malformed.
916
+ */
917
+ export async function loadScripts() {
918
+ const path = scriptsPath();
919
+ let raw;
920
+ try { raw = await readFile(path, 'utf8'); } catch { return null; }
921
+ try { return JSON.parse(raw); } catch (err) {
922
+ throw new SyntaxError(`Invalid JSON in .dbo/scripts.json: ${err.message}`);
923
+ }
924
+ }
925
+
926
+ /**
927
+ * Load .dbo/scripts.local.json (gitignored per-user overrides).
928
+ * Returns parsed object or null if missing.
929
+ * Throws SyntaxError with clear message if JSON is malformed.
930
+ */
931
+ export async function loadScriptsLocal() {
932
+ const path = scriptsLocalPath();
933
+ let raw;
934
+ try { raw = await readFile(path, 'utf8'); } catch { return null; }
935
+ try { return JSON.parse(raw); } catch (err) {
936
+ throw new SyntaxError(`Invalid JSON in .dbo/scripts.local.json: ${err.message}`);
937
+ }
938
+ }
939
+
940
+ // ─── Tag Config ───────────────────────────────────────────────────────────────
941
+
942
+ /**
943
+ * Load the tagFiles setting from config.json.
944
+ * @returns {Promise<{tagFiles: boolean}>} defaults to true
945
+ */
946
+ export async function loadTagConfig() {
947
+ try {
948
+ const raw = await readFile(join(process.cwd(), DBO_DIR, CONFIG_FILE), 'utf8');
949
+ const config = JSON.parse(raw);
950
+ return { tagFiles: config.tagFiles !== false };
951
+ } catch {
952
+ return { tagFiles: true };
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Enable or disable automatic file tagging by writing to config.json.
958
+ * @param {boolean} enabled
959
+ */
960
+ export async function saveTagConfig(enabled) {
961
+ const configPath = join(process.cwd(), DBO_DIR, CONFIG_FILE);
962
+ let config = {};
963
+ try { config = JSON.parse(await readFile(configPath, 'utf8')); } catch {}
964
+ config.tagFiles = enabled;
965
+ await writeFile(configPath, JSON.stringify(config, null, 2));
966
+ }
package/src/lib/delta.js CHANGED
@@ -81,7 +81,13 @@ export async function compareFileContent(filePath, baselineValue) {
81
81
 
82
82
  return normalizedCurrent !== normalizedBaseline;
83
83
  } catch (err) {
84
- // If file doesn't exist or can't be read, consider it changed
84
+ if (err.code === 'ENOENT') {
85
+ // File doesn't exist on disk — nothing to push regardless of baseline.
86
+ // This commonly happens with CustomSQL companion files that clone
87
+ // didn't extract (e.g. non-CustomSQL type with server-side content).
88
+ return false;
89
+ }
90
+ // Other read errors — treat as changed to be safe
85
91
  log.warn(`Failed to read ${filePath}: ${err.message}`);
86
92
  return true;
87
93
  }
@@ -0,0 +1,137 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { join, relative, extname, basename } from 'path';
3
+ import { log } from './logger.js';
4
+
5
+ const DEPLOY_CONFIG_FILE = '.dbo/deploy_config.json';
6
+
7
+ function deployConfigPath() {
8
+ return join(process.cwd(), DEPLOY_CONFIG_FILE);
9
+ }
10
+
11
+ function sortedKeys(obj) {
12
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
13
+ }
14
+
15
+ /**
16
+ * Load .dbo/deploy_config.json. Returns { deployments: {} } if missing.
17
+ * Throws on malformed JSON (do not silently recreate — would lose existing entries).
18
+ */
19
+ export async function loadDeployConfig() {
20
+ try {
21
+ const raw = await readFile(deployConfigPath(), 'utf8');
22
+ return JSON.parse(raw);
23
+ } catch (err) {
24
+ if (err.code === 'ENOENT') return { deployments: {} };
25
+ throw new Error(`Failed to parse ${DEPLOY_CONFIG_FILE}: ${err.message}`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Write .dbo/deploy_config.json with alphabetically sorted deployment keys.
31
+ */
32
+ export async function saveDeployConfig(config) {
33
+ await mkdir(join(process.cwd(), '.dbo'), { recursive: true });
34
+ const sorted = { ...config, deployments: sortedKeys(config.deployments || {}) };
35
+ await writeFile(deployConfigPath(), JSON.stringify(sorted, null, 2) + '\n');
36
+ }
37
+
38
+ /**
39
+ * Derive the <ext>:<basename> deploy key from a relative file path.
40
+ * Finds the shortest key not already occupied in existingDeployments.
41
+ *
42
+ * @param {string} relPath - e.g. "lib/bins/app/assets/css/colors.css"
43
+ * @param {Object} existingDeployments - current deployments object (to avoid collisions)
44
+ * @returns {string} - e.g. "css:colors"
45
+ */
46
+ export function buildDeployKey(relPath, existingDeployments = {}) {
47
+ const normalized = relPath.replace(/\\/g, '/');
48
+ const parts = normalized.split('/');
49
+ const filename = parts[parts.length - 1];
50
+ const ext = extname(filename).slice(1).toLowerCase(); // '' if no dot
51
+ const base = ext ? basename(filename, `.${ext}`) : filename;
52
+ const prefix = ext || 'file';
53
+
54
+ // Try simplest key first, then progressively add parent segments for disambiguation
55
+ const dirParts = parts.slice(0, -1);
56
+ const candidates = [`${prefix}:${base}`];
57
+ for (let depth = 1; depth <= dirParts.length; depth++) {
58
+ const suffix = dirParts.slice(-depth).join('/');
59
+ candidates.push(`${prefix}:${suffix}/${base}`);
60
+ }
61
+ candidates.push(`${prefix}:${normalized}`); // absolute fallback
62
+
63
+ for (const key of candidates) {
64
+ if (!existingDeployments[key]) return key;
65
+ }
66
+ return `${prefix}:${normalized}`; // always unique
67
+ }
68
+
69
+ /**
70
+ * Insert or update a deploy config entry for a companion file.
71
+ *
72
+ * Behaviour:
73
+ * - If an entry with the same UID already exists → update file, entity, column in place
74
+ * (keeps the existing key, avoids duplicate keys on re-clone)
75
+ * - If a new key collision exists with a DIFFERENT UID → log a warning, skip
76
+ * - Otherwise → create new entry with the shortest unique key
77
+ *
78
+ * @param {string} companionPath - Absolute path to the companion file
79
+ * @param {string} uid - Record UID (from metadata)
80
+ * @param {string} entity - Entity type (e.g. 'content', 'extension', 'media')
81
+ * @param {string} [column] - Column name (e.g. 'Content', 'CSS')
82
+ */
83
+ export async function upsertDeployEntry(companionPath, uid, entity, column) {
84
+ if (!uid || !companionPath) return;
85
+ const config = await loadDeployConfig();
86
+ const { deployments } = config;
87
+
88
+ const relPath = relative(process.cwd(), companionPath).replace(/\\/g, '/');
89
+
90
+ // Find if this UID is already tracked under any key
91
+ const existingKey = Object.keys(deployments).find(k => deployments[k].uid === uid);
92
+
93
+ if (existingKey) {
94
+ // Update path + metadata in place — keeps existing key, avoids duplicates on re-clone
95
+ deployments[existingKey] = {
96
+ ...deployments[existingKey],
97
+ file: relPath,
98
+ entity,
99
+ ...(column ? { column } : {}),
100
+ };
101
+ config.deployments = sortedKeys(deployments);
102
+ await saveDeployConfig(config);
103
+ return;
104
+ }
105
+
106
+ // New entry — build unique key avoiding all occupied keys
107
+ const key = buildDeployKey(relPath, deployments);
108
+
109
+ if (deployments[key]) {
110
+ // buildDeployKey returned an occupied key (should not happen — means all path segments collide)
111
+ log.warn(`Deploy config: skipping auto-registration of "${relPath}" — key "${key}" already exists for a different record. Add manually to ${DEPLOY_CONFIG_FILE}.`);
112
+ return;
113
+ }
114
+
115
+ const entry = { uid, file: relPath, entity };
116
+ if (column) entry.column = column;
117
+ deployments[key] = entry;
118
+ config.deployments = sortedKeys(deployments);
119
+ await saveDeployConfig(config);
120
+ }
121
+
122
+ /**
123
+ * Remove the deploy config entry matching a given UID.
124
+ * No-op if no entry with that UID exists.
125
+ *
126
+ * @param {string} uid - Record UID to remove
127
+ */
128
+ export async function removeDeployEntry(uid) {
129
+ if (!uid) return;
130
+ const config = await loadDeployConfig();
131
+ const { deployments } = config;
132
+ const key = Object.keys(deployments).find(k => deployments[k].uid === uid);
133
+ if (!key) return;
134
+ delete deployments[key];
135
+ config.deployments = sortedKeys(deployments);
136
+ await saveDeployConfig(config);
137
+ }
package/src/lib/diff.js CHANGED
@@ -5,6 +5,7 @@ import { loadIgnore } from './ignore.js';
5
5
  import { parseServerDate, setFileTimestamps } from './timestamps.js';
6
6
  import { loadConfig, loadUserInfo, loadAppJsonBaseline } from './config.js';
7
7
  import { findBaselineEntry } from './delta.js';
8
+ import { isMetadataFile, parseMetaFilename } from './filenames.js';
8
9
  import { log } from './logger.js';
9
10
 
10
11
  // ─── Baseline Cache ─────────────────────────────────────────────────────────
@@ -155,10 +156,10 @@ export async function findMetadataFiles(dir, ig) {
155
156
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
156
157
  if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
157
158
  results.push(...await findMetadataFiles(fullPath, ig));
158
- } else if (entry.name.endsWith('.metadata.json')) {
159
+ } else if (isMetadataFile(entry.name) && !entry.name.startsWith('__WILL_DELETE__')) {
159
160
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
160
161
  if (!ig.ignores(relPath)) results.push(fullPath);
161
- } else if (entry.name.endsWith('.json') && !entry.name.endsWith('.metadata.json') && !entry.name.includes('.CustomSQL.') && entry.name.includes('~')) {
162
+ } else if (entry.name.endsWith('.json') && !isMetadataFile(entry.name) && !entry.name.includes('.CustomSQL.') && entry.name.includes('~')) {
162
163
  // Output hierarchy root files: Name~UID.json (or legacy _output~Name~UID.json)
163
164
  // Exclude old-format child output files — they contain a dot-prefixed
164
165
  // child-type segment (.column~, .join~, .filter~) before .json.
@@ -217,6 +218,14 @@ export async function hasLocalModifications(metaPath, config = {}) {
217
218
  const metaDir = dirname(metaPath);
218
219
  const contentCols = meta._contentColumns || [];
219
220
 
221
+ // Load baseline for content comparison fallback (build tools can
222
+ // rewrite files with identical content, bumping mtime without any
223
+ // real change — e.g. sass recompile producing the same CSS).
224
+ const baseline = _cachedBaseline || await loadAppJsonBaseline();
225
+ const baselineEntry = baseline
226
+ ? findBaselineEntry(baseline, meta._entity, meta.UID)
227
+ : null;
228
+
220
229
  for (const col of contentCols) {
221
230
  const ref = meta[col];
222
231
  if (ref && String(ref).startsWith('@')) {
@@ -224,6 +233,20 @@ export async function hasLocalModifications(metaPath, config = {}) {
224
233
  try {
225
234
  const contentStat = await stat(contentPath);
226
235
  if (contentStat.mtime.getTime() > syncTime + 2000) {
236
+ // mtime says modified — verify with content comparison against
237
+ // baseline to rule out false positives from build tools.
238
+ if (baselineEntry && baselineEntry[col] != null) {
239
+ try {
240
+ const localContent = await readFile(contentPath, 'utf8');
241
+ const normalizedLocal = localContent.replace(/\r\n/g, '\n').trimEnd();
242
+ const normalizedBaseline = String(baselineEntry[col]).replace(/\r\n/g, '\n').trimEnd();
243
+ if (normalizedLocal === normalizedBaseline) {
244
+ // Content identical — false positive mtime bump, fix timestamp
245
+ await setFileTimestamps(contentPath, meta._CreatedOn, meta._LastUpdated, serverTz);
246
+ continue;
247
+ }
248
+ } catch { /* read failed — treat as modified */ }
249
+ }
227
250
  return true;
228
251
  }
229
252
  } catch { /* missing file */ }
@@ -473,7 +496,7 @@ export async function compareRecord(metaPath, config, serverRecordsMap) {
473
496
  }
474
497
 
475
498
  const metaDir = dirname(metaPath);
476
- const metaBase = basename(metaPath, '.metadata.json');
499
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
477
500
  const contentCols = localMeta._contentColumns || [];
478
501
  const fieldDiffs = [];
479
502
 
@@ -687,7 +710,7 @@ function buildChangeMessage(recordName, serverRecord, config, options = {}) {
687
710
  */
688
711
  function formatDateHint(serverDate, localDate) {
689
712
  const fmt = (d) => d instanceof Date && !isNaN(d)
690
- ? d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
713
+ ? d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })
691
714
  : null;
692
715
  const s = fmt(serverDate);
693
716
  const l = fmt(localDate);
@@ -777,7 +800,7 @@ export async function inlineDiffAndMerge(serverRow, metaPath, config, options =
777
800
  }
778
801
 
779
802
  const metaDir = dirname(metaPath);
780
- const metaBase = basename(metaPath, '.metadata.json');
803
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
781
804
  const contentCols = localMeta._contentColumns || [];
782
805
  const fieldDiffs = [];
783
806