@dboio/cli 0.13.2 → 0.15.1

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,15 +12,25 @@ 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
- import { findUnaddedFiles, detectBinFile, submitAdd } from './add.js';
23
+ // AUTO-ADD DISABLED: 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
+ }
24
34
 
25
35
  /**
26
36
  * Resolve an @reference file path to an absolute filesystem path.
@@ -86,7 +96,7 @@ export const pushCommand = new Command('push')
86
96
  const transactionKey = await resolveTransactionKey(options);
87
97
 
88
98
  // Process pending deletions from synchronize.json
89
- await processPendingDeletes(client, options, modifyKey, transactionKey);
99
+ const deletedCount = await processPendingDeletes(client, options, modifyKey, transactionKey) || 0;
90
100
 
91
101
  // ── Resolution order ──────────────────────────────────────────
92
102
  // 1. Commas → UID list
@@ -125,7 +135,7 @@ export const pushCommand = new Command('push')
125
135
  log.dim(` Found: ${relative(process.cwd(), resolved)}`);
126
136
  const resolvedStat = await stat(resolved);
127
137
  if (resolvedStat.isDirectory()) {
128
- await pushDirectory(resolved, client, options, modifyKey, transactionKey);
138
+ await pushDirectory(resolved, client, options, modifyKey, transactionKey, deletedCount);
129
139
  } else {
130
140
  await pushSingleFile(resolved, client, options, modifyKey, transactionKey);
131
141
  }
@@ -146,7 +156,7 @@ export const pushCommand = new Command('push')
146
156
  }
147
157
 
148
158
  if (pathStat.isDirectory()) {
149
- await pushDirectory(targetPath, client, options, modifyKey, transactionKey);
159
+ await pushDirectory(targetPath, client, options, modifyKey, transactionKey, deletedCount);
150
160
  } else {
151
161
  await pushSingleFile(targetPath, client, options, modifyKey, transactionKey);
152
162
  }
@@ -161,18 +171,27 @@ export const pushCommand = new Command('push')
161
171
  */
162
172
  async function processPendingDeletes(client, options, modifyKey = null, transactionKey = 'RowUID') {
163
173
  const sync = await loadSynchronize();
164
- if (!sync.delete || sync.delete.length === 0) return;
174
+ if (!sync.delete || sync.delete.length === 0) return 0;
165
175
 
166
176
  log.info(`Processing ${sync.delete.length} pending deletion(s)...`);
167
177
 
168
178
  const remaining = [];
169
179
  const deletedUids = [];
170
180
 
181
+ // Load stored ticket once for all deletions (same as main push loop)
182
+ const { getGlobalTicket, getRecordTicket } = await import('../lib/ticketing.js');
183
+ const globalTicket = !options.ticket ? await getGlobalTicket() : null;
184
+
171
185
  for (const entry of sync.delete) {
172
186
  log.info(`Deleting "${entry.name}" (${entry.entity}:${entry.RowID})`);
173
187
 
174
188
  const extraParams = { '_confirm': options.confirm || 'true' };
175
- if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
189
+ if (options.ticket) {
190
+ extraParams['_OverrideTicketID'] = options.ticket;
191
+ } else {
192
+ const ticket = await getRecordTicket(entry.UID) || globalTicket;
193
+ if (ticket) extraParams['_OverrideTicketID'] = ticket;
194
+ }
176
195
  if (modifyKey) extraParams['_modify_key'] = modifyKey;
177
196
  const cachedUser2 = getSessionUserOverride();
178
197
  if (cachedUser2) extraParams['_OverrideUserID'] = cachedUser2;
@@ -234,6 +253,8 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
234
253
  if (remaining.length > 0) {
235
254
  log.warn(`${remaining.length} deletion(s) failed and remain staged.`);
236
255
  }
256
+
257
+ return deletedUids.length;
237
258
  }
238
259
 
239
260
  /**
@@ -306,13 +327,20 @@ async function moveWillDeleteToTrash(entry) {
306
327
  async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
307
328
  // Find the metadata file
308
329
  let metaPath;
309
- if (filePath.endsWith('.metadata.json')) {
330
+ if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
310
331
  // User passed the metadata file directly — use it as-is
311
332
  metaPath = filePath;
312
333
  } else {
313
- const dir = dirname(filePath);
314
- const base = basename(filePath, extname(filePath));
315
- metaPath = join(dir, `${base}.metadata.json`);
334
+ // Try findMetadataForCompanion first (handles both new and legacy formats)
335
+ const found = await findMetadataForCompanion(filePath);
336
+ if (found) {
337
+ metaPath = found;
338
+ } else {
339
+ // Fallback: old convention
340
+ const dir = dirname(filePath);
341
+ const base = basename(filePath, extname(filePath));
342
+ metaPath = join(dir, `${base}.metadata.json`);
343
+ }
316
344
  }
317
345
 
318
346
  let meta;
@@ -326,27 +354,12 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
326
354
  try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch {}
327
355
  }
328
356
  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 foundauto-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
- }
357
+ // AUTO-ADD DISABLED: local-file-first approach is being replaced by server-first workflow.
358
+ // New records should be created via the DBO REST API first, then `dbo pull` to populate locally.
359
+ // The auto-add code (detectBinFile + submitAdd) has been commented out intentionally.
360
+ // See: .claude/2_specs/auto-deploy-config-generation.md"server-first approach"
361
+ log.error(`No metadata found for "${basename(filePath)}". Create the record on the server first, then run "dbo pull".`);
362
+ process.exit(1);
350
363
  }
351
364
  }
352
365
 
@@ -456,61 +469,16 @@ async function ensureManifestMetadata() {
456
469
  /**
457
470
  * Push all records found in a directory (recursive)
458
471
  */
459
- async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
472
+ async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID', deletedCount = 0) {
460
473
  // Auto-create manifest.metadata.json if manifest.json exists at root without companion metadata
461
474
  await ensureManifestMetadata();
462
475
 
463
- // ── Auto-add: detect un-added files and create+submit them before push ──
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
- }
476
+ // AUTO-ADD DISABLED: local-file-first approach is being replaced by server-first workflow.
477
+ // New records should be created via the DBO REST API first, then `dbo pull` to populate locally.
478
+ // The auto-add code (findUnaddedFiles + detectBinFile + submitAdd) has been commented out.
479
+ // See: .claude/2_specs/auto-deploy-config-generation.md — "server-first approach"
513
480
 
481
+ const ig = await loadIgnore();
514
482
  const metaFiles = await findMetadataFiles(dirPath, ig);
515
483
 
516
484
  // ── Load scripts config early (before delta detection) ──────────────
@@ -597,16 +565,16 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
597
565
  continue;
598
566
  }
599
567
 
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
- }
568
+ // AUTO-ADD DISABLED: justAddedUIDs skip removed (server-first workflow)
605
569
 
606
- // Compound output files: handle root + all inline children together
607
- // These have _entity='output' and inline children under .children
608
- if (meta._entity === 'output' && meta.children) {
609
- outputCompoundFiles.push({ meta, metaPath });
570
+ // Output hierarchy entities: only push compound output files (root with inline children).
571
+ // Flat output metadata without .children are skipped — they are pushed as part of
572
+ // their parent compound file, or via `dbo deploy`. Never as standalone records.
573
+ if (meta._entity === 'output' || meta._entity === 'output_value'
574
+ || meta._entity === 'output_value_filter' || meta._entity === 'output_value_entity_column_rel') {
575
+ if (meta._entity === 'output' && meta.children) {
576
+ outputCompoundFiles.push({ meta, metaPath });
577
+ }
610
578
  continue;
611
579
  }
612
580
 
@@ -702,23 +670,44 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
702
670
  }
703
671
  } catch { /* structure file missing or bin lookup failed — skip */ }
704
672
 
705
- if (toPush.length === 0 && outputCompoundFiles.length === 0 && binPushItems.length === 0) {
706
- if (metaFiles.length === 0) {
673
+ // Pre-filter compound output files: run delta detection early so unchanged outputs
674
+ // are excluded from the record count and ticket prompt (avoids false-positive prompts).
675
+ const outputsWithChanges = [];
676
+ for (const item of outputCompoundFiles) {
677
+ if (baseline) {
678
+ try {
679
+ const delta = await detectOutputChanges(item.metaPath, baseline);
680
+ const totalChanges = delta.root.length +
681
+ (delta.children ? Object.values(delta.children).reduce((s, c) => s + c.length, 0) : 999);
682
+ if (totalChanges === 0) {
683
+ log.dim(` Skipping ${basename(item.metaPath)} — no changes detected`);
684
+ skipped++;
685
+ continue;
686
+ }
687
+ } catch { /* delta detection failed — include in push for safety */ }
688
+ }
689
+ outputsWithChanges.push(item);
690
+ }
691
+
692
+ if (toPush.length === 0 && outputsWithChanges.length === 0 && binPushItems.length === 0) {
693
+ if (metaFiles.length === 0 && deletedCount === 0) {
707
694
  log.warn(`No .metadata.json files found in "${dirPath}".`);
695
+ } else if (deletedCount > 0) {
696
+ log.info(`${deletedCount} deletion(s) processed. No other changes to push.`);
708
697
  } else {
709
698
  log.info('No changes to push');
710
699
  }
711
700
  return;
712
701
  }
713
702
 
714
- log.info(`Found ${metaFiles.length} record(s) to push`);
703
+ log.info(`Found ${toPush.length + outputsWithChanges.length + binPushItems.length} record(s) to push`);
715
704
 
716
705
  // Pre-flight ticket validation (only if no --ticket flag)
717
- const totalRecords = toPush.length + outputCompoundFiles.length + binPushItems.length;
706
+ const totalRecords = toPush.length + outputsWithChanges.length + binPushItems.length;
718
707
  if (!options.ticket && totalRecords > 0) {
719
708
  const recordSummary = [
720
- ...toPush.map(r => basename(r.metaPath, '.metadata.json')),
721
- ...outputCompoundFiles.map(r => basename(r.metaPath, '.json')),
709
+ ...toPush.map(r => { const p = parseMetaFilename(basename(r.metaPath)); return p ? p.naturalBase : basename(r.metaPath, '.metadata.json'); }),
710
+ ...outputsWithChanges.map(r => basename(r.metaPath, '.json')),
722
711
  ...binPushItems.map(r => `bin:${r.meta.Name}`),
723
712
  ].join(', ');
724
713
  const ticketCheck = await checkStoredTicket(options, `${totalRecords} record(s): ${recordSummary}`);
@@ -818,8 +807,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
818
807
  }
819
808
  }
820
809
 
821
- // Process compound output files (root + inline children)
822
- for (const { meta, metaPath } of outputCompoundFiles) {
810
+ // Process compound output files (root + inline children) — already pre-filtered for changes
811
+ for (const { meta, metaPath } of outputsWithChanges) {
823
812
  try {
824
813
  const result = await pushOutputCompound(meta, metaPath, client, options, baseline, modifyKey, transactionKey);
825
814
  if (result.pushed > 0) {
@@ -861,6 +850,13 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
861
850
  await updateBaselineAfterPush(baseline, successfulPushes);
862
851
  }
863
852
 
853
+ // Re-tag successfully pushed files as Synced (best-effort)
854
+ for (const { meta, metaPath } of successfulPushes) {
855
+ for (const filePath of _getMetaCompanionPaths(meta, metaPath)) {
856
+ setFileTag(filePath, 'synced').catch(() => {});
857
+ }
858
+ }
859
+
864
860
  log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
865
861
  }
866
862
 
@@ -1221,7 +1217,8 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1221
1217
  }
1222
1218
 
1223
1219
  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}`);
1220
+ const pushDisplayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
1221
+ log.info(`Pushing ${pushDisplayName} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
1225
1222
 
1226
1223
  // Apply stored ticket if no --ticket flag
1227
1224
  const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
@@ -1449,7 +1446,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
1449
1446
  if (ENTITY_DIR_NAMES.has(entity)) return;
1450
1447
 
1451
1448
  const metaDir = dirname(metaPath);
1452
- const metaBase = basename(metaPath, '.metadata.json');
1449
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
1453
1450
 
1454
1451
  // Find the content file referenced by @filename
1455
1452
  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
+ }