@dboio/cli 0.13.2 → 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.
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { loadConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../lib/config.js';
2
+ import { loadConfig, initConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../lib/config.js';
3
3
  import { DboClient } from '../lib/client.js';
4
4
  import { log } from '../lib/logger.js';
5
5
 
@@ -13,13 +13,24 @@ import { log } from '../lib/logger.js';
13
13
  */
14
14
  export async function performLogin(domain, knownUsername) {
15
15
  const config = await loadConfig();
16
+ const inquirer = (await import('inquirer')).default;
17
+
18
+ // Prompt for domain if not provided and not configured
19
+ if (!domain && !config.domain) {
20
+ const { domain: inputDomain } = await inquirer.prompt([
21
+ { type: 'input', name: 'domain', message: 'Domain (e.g. myapp.dbo.io):' },
22
+ ]);
23
+ if (!inputDomain) throw new Error('Domain is required.');
24
+ domain = inputDomain.trim();
25
+ await initConfig(domain);
26
+ }
27
+
16
28
  const client = new DboClient({ domain });
17
29
 
18
30
  let username = knownUsername || config.username;
19
31
  let password;
20
32
 
21
33
  // Interactive prompt for missing credentials
22
- const inquirer = (await import('inquirer')).default;
23
34
  const answers = await inquirer.prompt([
24
35
  { type: 'input', name: 'username', message: 'Username (email):', default: username || undefined, when: !username },
25
36
  { type: 'password', name: 'password', message: 'Password:', mask: '*' },
@@ -83,7 +94,23 @@ export const loginCommand = new Command('login')
83
94
  .action(async (options) => {
84
95
  try {
85
96
  const config = await loadConfig();
86
- const client = new DboClient({ domain: options.domain });
97
+ const inquirer = (await import('inquirer')).default;
98
+ let domain = options.domain;
99
+
100
+ // Prompt for domain if not provided and not configured
101
+ if (!domain && !config.domain) {
102
+ const { domain: inputDomain } = await inquirer.prompt([
103
+ { type: 'input', name: 'domain', message: 'Domain (e.g. myapp.dbo.io):' },
104
+ ]);
105
+ if (!inputDomain) {
106
+ log.error('Domain is required.');
107
+ process.exit(1);
108
+ }
109
+ domain = inputDomain.trim();
110
+ await initConfig(domain);
111
+ }
112
+
113
+ const client = new DboClient({ domain });
87
114
 
88
115
  let username = options.username || options.email || options.phone;
89
116
  let password = options.password || options.passkey;
@@ -93,7 +120,6 @@ export const loginCommand = new Command('login')
93
120
 
94
121
  // Interactive prompt if still missing
95
122
  if (!username || !password) {
96
- const inquirer = (await import('inquirer')).default;
97
123
  const answers = await inquirer.prompt([
98
124
  { type: 'input', name: 'username', message: 'Username (email):', default: config.username, when: !username },
99
125
  { type: 'password', name: 'password', message: 'Password:', mask: '*', when: !password },
@@ -13,8 +13,9 @@ 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
+ import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
17
17
  import { runPendingMigrations } from '../lib/migrations.js';
18
+ import { removeDeployEntry, upsertDeployEntry } from '../lib/deploy-config.js';
18
19
 
19
20
  export const mvCommand = new Command('mv')
20
21
  .description('Move files or bins to a new location and update metadata')
@@ -200,7 +201,7 @@ function checkCircularReference(sourceBinId, targetBinId, structure) {
200
201
  * Resolve a file path to its metadata.json path.
201
202
  */
202
203
  function resolveMetaPath(filePath) {
203
- if (filePath.endsWith('.metadata.json')) {
204
+ if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
204
205
  return filePath;
205
206
  }
206
207
  const dir = dirname(filePath);
@@ -544,13 +545,13 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
544
545
  // Calculate paths
545
546
  const targetDir = resolveBinPath(targetBinId, structure);
546
547
  const sourceDir = dirname(metaPath);
547
- const contentFileName = sourceFile.endsWith('.metadata.json')
548
+ const contentFileName = (isMetadataFile(basename(sourceFile)) || sourceFile.endsWith('.metadata.json'))
548
549
  ? null
549
550
  : basename(sourceFile);
550
551
  const metaFileName = basename(metaPath);
551
552
 
552
553
  // Determine display name
553
- const displayName = basename(metaPath, '.metadata.json');
554
+ const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
554
555
  const targetBin = structure[targetBinId];
555
556
  const targetBinName = targetBin ? targetBin.name : String(targetBinId);
556
557
  const targetBinFullPath = targetBin ? `${BINS_DIR}/${targetBin.fullPath}` : targetDir;
@@ -672,6 +673,18 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
672
673
  }
673
674
  }
674
675
 
676
+ if (!options.dryRun) {
677
+ // Update deploy config: remove old entry (by UID), re-insert with new path + correct key
678
+ await removeDeployEntry(uid);
679
+ if (newContentPath) {
680
+ const col = (meta._contentColumns || [])[0] || 'Content';
681
+ await upsertDeployEntry(newContentPath, uid, entity, col);
682
+ } else if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
683
+ const movedMediaPath = join(targetDir, String(meta._mediaFile).substring(1));
684
+ await upsertDeployEntry(movedMediaPath, uid, entity, 'File');
685
+ }
686
+ }
687
+
675
688
  if (options.dryRun) {
676
689
  log.info(`[DRY RUN] Would move "${displayName}" to "${targetBinName}" (${targetBinFullPath})`);
677
690
  } else {
@@ -12,16 +12,26 @@ import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, cl
12
12
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
13
13
  import { resolveTransactionKey } from '../lib/transaction-key.js';
14
14
  import { setFileTimestamps } from '../lib/timestamps.js';
15
- import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, findMetadataForCompanion } from '../lib/filenames.js';
15
+ import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
16
16
  import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
17
17
  import { loadIgnore } from '../lib/ignore.js';
18
18
  import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
19
19
  import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '../lib/structure.js';
20
- import { ensureTrashIcon } from '../lib/folder-icon.js';
20
+ import { ensureTrashIcon, setFileTag } from '../lib/tagging.js';
21
21
  import { checkToeStepping } from '../lib/toe-stepping.js';
22
22
  import { runPendingMigrations } from '../lib/migrations.js';
23
23
  import { findUnaddedFiles, detectBinFile, submitAdd } from './add.js';
24
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
+ }
34
+
25
35
  /**
26
36
  * Resolve an @reference file path to an absolute filesystem path.
27
37
  * "@filename.ext" → relative to the metadata file's directory (existing behaviour)
@@ -306,13 +316,20 @@ async function moveWillDeleteToTrash(entry) {
306
316
  async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
307
317
  // Find the metadata file
308
318
  let metaPath;
309
- if (filePath.endsWith('.metadata.json')) {
319
+ if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
310
320
  // User passed the metadata file directly — use it as-is
311
321
  metaPath = filePath;
312
322
  } else {
313
- const dir = dirname(filePath);
314
- const base = basename(filePath, extname(filePath));
315
- 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
+ }
316
333
  }
317
334
 
318
335
  let meta;
@@ -717,7 +734,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
717
734
  const totalRecords = toPush.length + outputCompoundFiles.length + binPushItems.length;
718
735
  if (!options.ticket && totalRecords > 0) {
719
736
  const recordSummary = [
720
- ...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'); }),
721
738
  ...outputCompoundFiles.map(r => basename(r.metaPath, '.json')),
722
739
  ...binPushItems.map(r => `bin:${r.meta.Name}`),
723
740
  ].join(', ');
@@ -861,6 +878,13 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
861
878
  await updateBaselineAfterPush(baseline, successfulPushes);
862
879
  }
863
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
+
864
888
  log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
865
889
  }
866
890
 
@@ -1221,7 +1245,8 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1221
1245
  }
1222
1246
 
1223
1247
  const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)${isMediaUpload ? ' + media file' : ''}` : `${dataExprs.length} field(s)`;
1224
- 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}`);
1225
1250
 
1226
1251
  // Apply stored ticket if no --ticket flag
1227
1252
  const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
@@ -1449,7 +1474,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
1449
1474
  if (ENTITY_DIR_NAMES.has(entity)) return;
1450
1475
 
1451
1476
  const metaDir = dirname(metaPath);
1452
- const metaBase = basename(metaPath, '.metadata.json');
1477
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
1453
1478
 
1454
1479
  // Find the content file referenced by @filename
1455
1480
  const contentCols = meta._contentColumns || [];
@@ -5,9 +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 { findMetadataForCompanion } from '../lib/filenames.js';
8
+ import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
9
9
  import { loadStructureFile, findBinByPath, findChildBins, BINS_DIR } from '../lib/structure.js';
10
10
  import { runPendingMigrations } from '../lib/migrations.js';
11
+ import { removeDeployEntry } from '../lib/deploy-config.js';
11
12
 
12
13
  export const rmCommand = new Command('rm')
13
14
  .description('Remove a file or directory locally and stage server deletions for the next dbo push')
@@ -42,7 +43,7 @@ export const rmCommand = new Command('rm')
42
43
  * Resolve a file path to its metadata.json path.
43
44
  */
44
45
  function resolveMetaPath(filePath) {
45
- if (filePath.endsWith('.metadata.json')) {
46
+ if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
46
47
  return filePath;
47
48
  }
48
49
  const dir = dirname(filePath);
@@ -101,7 +102,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
101
102
  localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
102
103
  }
103
104
 
104
- const displayName = basename(metaPath, '.metadata.json');
105
+ const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
105
106
 
106
107
  // Prompt if needed
107
108
  if (!skipPrompt && !options.force) {
@@ -121,6 +122,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
121
122
  // Stage deletion (include metaPath for Trash workflow in push.js)
122
123
  const expression = `RowID:del${rowId};entity:${entity}=true`;
123
124
  await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
125
+ await removeDeployEntry(uid);
124
126
  log.success(` Staged: ${displayName} → ${expression}`);
125
127
 
126
128
  // Remove from app.json
@@ -205,7 +207,7 @@ async function rmFile(filePath, options) {
205
207
  localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
206
208
  }
207
209
 
208
- const displayName = basename(metaPath, '.metadata.json');
210
+ const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
209
211
  log.info(`Removing "${displayName}" (${entity}:${uid || rowId})`);
210
212
  for (const f of localFiles) {
211
213
  log.dim(` ${f}`);
@@ -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
@@ -936,3 +936,31 @@ export async function loadScriptsLocal() {
936
936
  throw new SyntaxError(`Invalid JSON in .dbo/scripts.local.json: ${err.message}`);
937
937
  }
938
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
+ }
@@ -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') && !entry.name.startsWith('__WILL_DELETE__')) {
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.
@@ -495,7 +496,7 @@ export async function compareRecord(metaPath, config, serverRecordsMap) {
495
496
  }
496
497
 
497
498
  const metaDir = dirname(metaPath);
498
- const metaBase = basename(metaPath, '.metadata.json');
499
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
499
500
  const contentCols = localMeta._contentColumns || [];
500
501
  const fieldDiffs = [];
501
502
 
@@ -799,7 +800,7 @@ export async function inlineDiffAndMerge(serverRow, metaPath, config, options =
799
800
  }
800
801
 
801
802
  const metaDir = dirname(metaPath);
802
- const metaBase = basename(metaPath, '.metadata.json');
803
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
803
804
  const contentCols = localMeta._contentColumns || [];
804
805
  const fieldDiffs = [];
805
806