@dboio/cli 0.4.1 → 0.5.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.
@@ -0,0 +1,869 @@
1
+ import { Command } from 'commander';
2
+ import { readFile, writeFile, stat, rename, mkdir, utimes } from 'fs/promises';
3
+ import { join, dirname, basename, extname, relative, isAbsolute } from 'path';
4
+ import { log } from '../lib/logger.js';
5
+ import { formatError } from '../lib/formatter.js';
6
+ import { loadSynchronize, saveSynchronize } from '../lib/config.js';
7
+ import {
8
+ loadStructureFile,
9
+ saveStructureFile,
10
+ findBinByPath,
11
+ findChildBins,
12
+ resolveBinPath,
13
+ BINS_DIR
14
+ } from '../lib/structure.js';
15
+ import { findMetadataFiles } from '../lib/diff.js';
16
+
17
+ export const mvCommand = new Command('mv')
18
+ .description('Move files or bins to a new location and update metadata')
19
+ .argument('<source>', 'File or directory to move')
20
+ .argument('[destination]', 'Target directory path, BinID, or UID (omit for interactive prompt)')
21
+ .option('-f, --force', 'Skip confirmation prompts')
22
+ .option('-v, --verbose', 'Show detailed operations')
23
+ .option('--dry-run', 'Preview changes without executing')
24
+ .option('-C, --confirm <value>', 'Commit changes (true|false)', 'true')
25
+ .action(async (source, destination, options) => {
26
+ try {
27
+ // Only items inside Bins/ are moveable
28
+ const relSource = isAbsolute(source) ? relative(process.cwd(), source) : source;
29
+ const normalized = relSource.replace(/\/+$/, '');
30
+ if (!normalized.startsWith(`${BINS_DIR}/`) && normalized !== BINS_DIR) {
31
+ log.error(`Only files and directories inside "${BINS_DIR}/" can be moved.`);
32
+ log.dim(' Root project directories (App Versions, Documentation, Sites, etc.) are part of the DBO app structure and cannot be moved.');
33
+ process.exit(1);
34
+ }
35
+
36
+ const pathStat = await stat(source).catch(() => null);
37
+ if (!pathStat) {
38
+ log.error(`Source path not found: "${source}"`);
39
+ process.exit(1);
40
+ }
41
+
42
+ const structure = await loadStructureFile();
43
+
44
+ // Resolve destination BinID
45
+ let targetBinId;
46
+ if (destination) {
47
+ targetBinId = resolveBinId(destination, structure);
48
+ if (targetBinId === null) {
49
+ log.error(`Invalid destination: "${destination}"`);
50
+ log.dim(' Must be: path, BinID, or UID');
51
+ process.exit(1);
52
+ }
53
+ } else {
54
+ targetBinId = await promptForBin(structure);
55
+ if (targetBinId === null) {
56
+ log.dim('Cancelled.');
57
+ return;
58
+ }
59
+ }
60
+
61
+ // Validate target exists in structure
62
+ if (!structure[targetBinId]) {
63
+ log.error(`BinID ${targetBinId} not found in .dbo/structure.json`);
64
+ log.dim(' Run "dbo clone" to refresh project structure.');
65
+ process.exit(1);
66
+ }
67
+
68
+ if (pathStat.isDirectory()) {
69
+ await mvBin(source, targetBinId, structure, options);
70
+ } else {
71
+ await mvFile(source, targetBinId, structure, options);
72
+ }
73
+ } catch (err) {
74
+ formatError(err);
75
+ process.exit(1);
76
+ }
77
+ });
78
+
79
+ // ── Resolution helpers ───────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Convert a destination (path, BinID, or UID) to a numeric BinID.
83
+ * Returns null if not found.
84
+ */
85
+ function resolveBinId(destination, structure) {
86
+ // Normalize absolute paths to relative
87
+ const dest = isAbsolute(destination) ? relative(process.cwd(), destination) : destination;
88
+
89
+ // Path format: contains /
90
+ if (dest.includes('/')) {
91
+ const bin = findBinByPath(dest, structure);
92
+ return bin ? bin.binId : null;
93
+ }
94
+
95
+ // Numeric BinID
96
+ if (/^\d+$/.test(dest)) {
97
+ const id = Number(dest);
98
+ return structure[id] ? id : null;
99
+ }
100
+
101
+ // 32-char hex UID
102
+ if (/^[a-f0-9]{32}$/i.test(dest)) {
103
+ for (const [binId, entry] of Object.entries(structure)) {
104
+ if (entry.uid === dest) {
105
+ return Number(binId);
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ // Try as path without slashes (single segment name)
112
+ const bin = findBinByPath(dest, structure);
113
+ return bin ? bin.binId : null;
114
+ }
115
+
116
+ /**
117
+ * Interactive bin selection prompt.
118
+ */
119
+ async function promptForBin(structure) {
120
+ const entries = Object.entries(structure)
121
+ .map(([id, e]) => ({ binId: Number(id), ...e }))
122
+ .sort((a, b) => (a.fullPath || '').localeCompare(b.fullPath || ''));
123
+
124
+ if (entries.length === 0) {
125
+ log.error('No bins found in .dbo/structure.json');
126
+ log.dim(' Run "dbo clone" to refresh project structure.');
127
+ process.exit(1);
128
+ }
129
+
130
+ const choices = entries.map(e => ({
131
+ name: `${e.name} (${e.binId}) - ${BINS_DIR}/${e.fullPath}`,
132
+ value: e.binId,
133
+ }));
134
+ choices.push({ name: '(Enter custom BinID or UID)', value: '_custom' });
135
+
136
+ const inquirer = (await import('inquirer')).default;
137
+ const { binChoice } = await inquirer.prompt([{
138
+ type: 'list',
139
+ name: 'binChoice',
140
+ message: 'Select destination bin:',
141
+ choices,
142
+ }]);
143
+
144
+ if (binChoice === '_custom') {
145
+ const { customValue } = await inquirer.prompt([{
146
+ type: 'input',
147
+ name: 'customValue',
148
+ message: 'Enter BinID or UID:',
149
+ validate: v => v.trim() ? true : 'BinID or UID is required',
150
+ }]);
151
+ return resolveBinId(customValue.trim(), structure);
152
+ }
153
+
154
+ return binChoice;
155
+ }
156
+
157
+ // ── Validation ───────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Validate a move operation before executing.
161
+ */
162
+ function validateMove(source, targetBinId, structure, options) {
163
+ const errors = [];
164
+ const warnings = [];
165
+
166
+ if (!structure[targetBinId]) {
167
+ errors.push(`Target BinID ${targetBinId} not found in structure.json`);
168
+ }
169
+
170
+ return { valid: errors.length === 0, errors, warnings };
171
+ }
172
+
173
+ /**
174
+ * Prevent moving a bin into its own subtree.
175
+ * Walks up the parent chain from targetBinId; if sourceBinId is encountered, it's circular.
176
+ */
177
+ function checkCircularReference(sourceBinId, targetBinId, structure) {
178
+ if (sourceBinId === targetBinId) return true;
179
+
180
+ let current = targetBinId;
181
+ const visited = new Set();
182
+ while (current !== null && current !== undefined) {
183
+ if (visited.has(current)) break; // safety: break infinite loop
184
+ visited.add(current);
185
+ if (current === sourceBinId) return true;
186
+ const entry = structure[current];
187
+ if (!entry) break;
188
+ current = entry.parentBinID;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ // ── Metadata helpers ─────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Resolve a file path to its metadata.json path.
197
+ */
198
+ function resolveMetaPath(filePath) {
199
+ if (filePath.endsWith('.metadata.json')) {
200
+ return filePath;
201
+ }
202
+ const dir = dirname(filePath);
203
+ const ext = extname(filePath);
204
+ const base = basename(filePath, ext);
205
+ return join(dir, `${base}.metadata.json`);
206
+ }
207
+
208
+ /**
209
+ * Get the entity-specific row ID from metadata.
210
+ */
211
+ function getRowId(meta) {
212
+ if (meta._id) return meta._id;
213
+ if (meta._entity) {
214
+ const entityIdKey = meta._entity.charAt(0).toUpperCase() + meta._entity.slice(1) + 'ID';
215
+ if (meta[entityIdKey]) return meta[entityIdKey];
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Update fields in a metadata JSON file and touch its mtime.
222
+ */
223
+ async function updateMetadata(metaPath, updates, options) {
224
+ if (options.dryRun) {
225
+ log.info(`[DRY RUN] Would update metadata: ${metaPath}`);
226
+ for (const [key, val] of Object.entries(updates)) {
227
+ log.dim(` ${key} = ${val}`);
228
+ }
229
+ return;
230
+ }
231
+
232
+ const raw = await readFile(metaPath, 'utf8');
233
+ const meta = JSON.parse(raw);
234
+ Object.assign(meta, updates);
235
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
236
+
237
+ // Touch mtime to mark as modified
238
+ const now = new Date();
239
+ await utimes(metaPath, now, now);
240
+
241
+ if (options.verbose) {
242
+ for (const [key, val] of Object.entries(updates)) {
243
+ log.verbose(`metadata ${key} = ${val}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ // ── Synchronize staging ──────────────────────────────────────────────────
249
+
250
+ /**
251
+ * Merge two edit expressions for the same UID.
252
+ * Combines column assignments from both expressions.
253
+ *
254
+ * existing: "RowUID:abc;column:content.Name=Test"
255
+ * incoming: "RowUID:abc;column:content.BinID=456"
256
+ * result: "RowUID:abc;column:content.Name=Test;column:content.BinID=456"
257
+ */
258
+ function mergeExpressions(existing, incoming) {
259
+ // Parse both into parts
260
+ const existingParts = existing.split(';');
261
+ const incomingParts = incoming.split(';');
262
+
263
+ // Separate RowUID/RowID prefix from column assignments
264
+ const prefix = existingParts.find(p => p.startsWith('RowUID:') || p.startsWith('RowID:'));
265
+ const existingColumns = existingParts.filter(p => p.startsWith('column:'));
266
+ const incomingColumns = incomingParts.filter(p => p.startsWith('column:'));
267
+
268
+ // Build a map of column assignments (key = entity.Field, value = full part)
269
+ const columnMap = new Map();
270
+ for (const col of existingColumns) {
271
+ const key = extractColumnKey(col);
272
+ columnMap.set(key, col);
273
+ }
274
+ for (const col of incomingColumns) {
275
+ const key = extractColumnKey(col);
276
+ columnMap.set(key, col); // incoming overwrites existing for same key
277
+ }
278
+
279
+ return [prefix, ...columnMap.values()].join(';');
280
+ }
281
+
282
+ /**
283
+ * Extract the column key (e.g. "content.BinID") from a column part like "column:content.BinID=456".
284
+ */
285
+ function extractColumnKey(columnPart) {
286
+ // column:entity.Field=value or column:entity.Field@filepath
287
+ const afterColon = columnPart.substring('column:'.length);
288
+ const eqIdx = afterColon.indexOf('=');
289
+ const atIdx = afterColon.indexOf('@');
290
+ if (eqIdx !== -1 && (atIdx === -1 || eqIdx < atIdx)) {
291
+ return afterColon.substring(0, eqIdx);
292
+ }
293
+ if (atIdx !== -1) {
294
+ return afterColon.substring(0, atIdx);
295
+ }
296
+ return afterColon;
297
+ }
298
+
299
+ /**
300
+ * Add or merge an edit entry in synchronize.json.
301
+ * Handles conflicts with existing delete entries.
302
+ *
303
+ * When `entry.originalValue` is provided (e.g. the original BinID before any
304
+ * local moves), the staged edit tracks it. If a subsequent move sets the value
305
+ * back to `originalValue`, the edit is removed entirely — the move is a no-op
306
+ * relative to the server state.
307
+ */
308
+ async function stageEdit(entry, options) {
309
+ if (options.dryRun) {
310
+ log.info(`[DRY RUN] Would stage edit: ${entry.expression}`);
311
+ return;
312
+ }
313
+
314
+ const data = await loadSynchronize();
315
+
316
+ // Check for existing delete entry with same UID
317
+ const deleteIdx = data.delete.findIndex(e => e.UID === entry.UID);
318
+ if (deleteIdx >= 0) {
319
+ if (!options.force) {
320
+ const inquirer = (await import('inquirer')).default;
321
+ const { proceed } = await inquirer.prompt([{
322
+ type: 'confirm',
323
+ name: 'proceed',
324
+ message: 'This item is staged for deletion. Move it anyway (will cancel deletion)?',
325
+ default: false,
326
+ }]);
327
+ if (!proceed) {
328
+ log.dim(' Skipped (staged for deletion)');
329
+ return;
330
+ }
331
+ }
332
+ // Remove from delete list
333
+ data.delete.splice(deleteIdx, 1);
334
+ if (options.verbose) log.verbose('Removed existing delete entry');
335
+ }
336
+
337
+ // Check for existing edit entry with same UID
338
+ if (!data.edit) data.edit = [];
339
+ const editIdx = data.edit.findIndex(e => e.UID === entry.UID);
340
+ if (editIdx >= 0) {
341
+ const existing = data.edit[editIdx];
342
+
343
+ // Preserve the original value from the first staged edit
344
+ const originalValue = existing.originalValue ?? entry.originalValue;
345
+
346
+ // If this move reverts back to the original value, remove the edit entirely
347
+ if (originalValue !== undefined && entry.targetValue === originalValue) {
348
+ data.edit.splice(editIdx, 1);
349
+ await saveSynchronize(data);
350
+ log.success(` Reverted to original location — staged edit removed`);
351
+ return;
352
+ }
353
+
354
+ // Otherwise merge expressions and preserve originalValue
355
+ existing.expression = mergeExpressions(existing.expression, entry.expression);
356
+ existing.originalValue = originalValue;
357
+ if (options.verbose) log.verbose(`Merged with existing edit: ${existing.expression}`);
358
+ } else {
359
+ // New edit entry — store originalValue for future revert detection
360
+ if (entry.originalValue !== undefined) {
361
+ // originalValue is stored on the entry, it will persist in synchronize.json
362
+ }
363
+ data.edit.push(entry);
364
+ }
365
+
366
+ await saveSynchronize(data);
367
+ }
368
+
369
+ // ── App.json updates ─────────────────────────────────────────────────────
370
+
371
+ /**
372
+ * Update a single @path reference in app.json.
373
+ */
374
+ async function updateAppJsonPath(oldMetaPath, newMetaPath, options) {
375
+ if (options.dryRun) {
376
+ log.info(`[DRY RUN] Would update app.json: @${oldMetaPath} → @${newMetaPath}`);
377
+ return;
378
+ }
379
+
380
+ const appJsonPath = join(process.cwd(), 'app.json');
381
+ let appJson;
382
+ try {
383
+ appJson = JSON.parse(await readFile(appJsonPath, 'utf8'));
384
+ } catch {
385
+ return; // no app.json
386
+ }
387
+
388
+ if (!appJson.children) return;
389
+
390
+ const oldRef = `@${oldMetaPath}`;
391
+ const newRef = `@${newMetaPath}`;
392
+ let changed = false;
393
+
394
+ for (const [key, arr] of Object.entries(appJson.children)) {
395
+ if (!Array.isArray(arr)) continue;
396
+ for (let i = 0; i < arr.length; i++) {
397
+ if (arr[i] === oldRef) {
398
+ arr[i] = newRef;
399
+ changed = true;
400
+ }
401
+ }
402
+ }
403
+
404
+ if (changed) {
405
+ await writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
406
+ if (options.verbose) log.verbose(`app.json: ${oldRef} → ${newRef}`);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Update all @path references that start with a given directory prefix in app.json.
412
+ */
413
+ async function updateAppJsonForDirectoryMove(oldDirPath, newDirPath, options) {
414
+ if (options.dryRun) {
415
+ log.info(`[DRY RUN] Would update app.json refs: @${oldDirPath}/... → @${newDirPath}/...`);
416
+ return;
417
+ }
418
+
419
+ const appJsonPath = join(process.cwd(), 'app.json');
420
+ let appJson;
421
+ try {
422
+ appJson = JSON.parse(await readFile(appJsonPath, 'utf8'));
423
+ } catch {
424
+ return;
425
+ }
426
+
427
+ if (!appJson.children) return;
428
+
429
+ const oldPrefix = `@${oldDirPath}/`;
430
+ const newPrefix = `@${newDirPath}/`;
431
+ let changed = false;
432
+
433
+ for (const [key, arr] of Object.entries(appJson.children)) {
434
+ if (!Array.isArray(arr)) continue;
435
+ for (let i = 0; i < arr.length; i++) {
436
+ if (arr[i].startsWith(oldPrefix)) {
437
+ arr[i] = newPrefix + arr[i].substring(oldPrefix.length);
438
+ changed = true;
439
+ }
440
+ }
441
+ }
442
+
443
+ if (changed) {
444
+ await writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
445
+ if (options.verbose) log.verbose(`app.json: updated directory refs ${oldDirPath} → ${newDirPath}`);
446
+ }
447
+ }
448
+
449
+ // ── Execute helper ───────────────────────────────────────────────────────
450
+
451
+ async function executeOperation(operation, description, options) {
452
+ if (options.dryRun) {
453
+ log.info(`[DRY RUN] Would ${description}`);
454
+ return;
455
+ }
456
+
457
+ if (options.verbose) log.dim(` ${description}...`);
458
+ await operation();
459
+ if (options.verbose) log.success(` ${description}`);
460
+ }
461
+
462
+ // ── File move ────────────────────────────────────────────────────────────
463
+
464
+ /**
465
+ * Check for file name conflict at destination and prompt for resolution.
466
+ * Returns { action: 'proceed' | 'rename' | 'cancel', newName? }
467
+ */
468
+ async function checkNameConflict(fileName, targetDir, options) {
469
+ const targetPath = join(targetDir, fileName);
470
+ const exists = await stat(targetPath).catch(() => null);
471
+ if (!exists) return { action: 'proceed' };
472
+
473
+ if (options.force) return { action: 'proceed' }; // overwrite silently
474
+
475
+ const inquirer = (await import('inquirer')).default;
476
+ const { action } = await inquirer.prompt([{
477
+ type: 'list',
478
+ name: 'action',
479
+ message: `File "${fileName}" already exists at destination. What would you like to do?`,
480
+ choices: [
481
+ { name: 'Overwrite existing file', value: 'overwrite' },
482
+ { name: 'Rename to avoid conflict', value: 'rename' },
483
+ { name: 'Cancel operation', value: 'cancel' },
484
+ ],
485
+ }]);
486
+
487
+ if (action === 'cancel') return { action: 'cancel' };
488
+ if (action === 'overwrite') return { action: 'proceed' };
489
+
490
+ // Rename
491
+ const { newName } = await inquirer.prompt([{
492
+ type: 'input',
493
+ name: 'newName',
494
+ message: 'Enter new file name:',
495
+ default: fileName,
496
+ validate: v => v.trim() ? true : 'File name is required',
497
+ }]);
498
+
499
+ return { action: 'rename', newName: newName.trim() };
500
+ }
501
+
502
+ /**
503
+ * Move a single file to a new bin.
504
+ */
505
+ async function mvFile(sourceFile, targetBinId, structure, options) {
506
+ // Resolve metadata
507
+ const metaPath = resolveMetaPath(sourceFile);
508
+ let meta;
509
+ try {
510
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
511
+ } catch {
512
+ log.error(`No metadata found for "${sourceFile}"`);
513
+ log.dim(' Use "dbo content pull" or "dbo output --save" to create metadata files.');
514
+ process.exit(1);
515
+ }
516
+
517
+ const entity = meta._entity;
518
+ const uid = meta.UID;
519
+ const rowId = getRowId(meta);
520
+ const currentBinId = meta.BinID;
521
+
522
+ if (!entity || !rowId) {
523
+ log.error(`Missing _entity or row ID in "${metaPath}". Cannot move.`);
524
+ process.exit(1);
525
+ }
526
+
527
+ // No-op detection
528
+ if (currentBinId === targetBinId) {
529
+ log.warn(`File is already in bin ${targetBinId}. Nothing to do.`);
530
+ return;
531
+ }
532
+
533
+ // Calculate paths
534
+ const targetDir = resolveBinPath(targetBinId, structure);
535
+ const sourceDir = dirname(metaPath);
536
+ const contentFileName = sourceFile.endsWith('.metadata.json')
537
+ ? null
538
+ : basename(sourceFile);
539
+ const metaFileName = basename(metaPath);
540
+
541
+ // Determine display name
542
+ const displayName = basename(metaPath, '.metadata.json');
543
+ const targetBin = structure[targetBinId];
544
+ const targetBinName = targetBin ? targetBin.name : String(targetBinId);
545
+ const targetBinFullPath = targetBin ? `${BINS_DIR}/${targetBin.fullPath}` : targetDir;
546
+
547
+ // Check for name conflict
548
+ const conflictFile = contentFileName || metaFileName;
549
+ const conflict = await checkNameConflict(conflictFile, targetDir, options);
550
+ if (conflict.action === 'cancel') {
551
+ log.dim('Cancelled.');
552
+ return;
553
+ }
554
+
555
+ // Resolve final file name (may be renamed)
556
+ let finalContentName = contentFileName;
557
+ let finalMetaName = metaFileName;
558
+ if (conflict.action === 'rename' && conflict.newName) {
559
+ if (contentFileName) {
560
+ finalContentName = conflict.newName;
561
+ const ext = extname(finalContentName);
562
+ const base = basename(finalContentName, ext);
563
+ finalMetaName = `${base}.metadata.json`;
564
+ } else {
565
+ finalMetaName = conflict.newName;
566
+ }
567
+ }
568
+
569
+ // Confirmation
570
+ if (!options.force && !options.dryRun) {
571
+ const inquirer = (await import('inquirer')).default;
572
+ const { confirm } = await inquirer.prompt([{
573
+ type: 'confirm',
574
+ name: 'confirm',
575
+ message: `Move "${displayName}" to "${targetBinName}" (${targetBinFullPath})?`,
576
+ default: true,
577
+ }]);
578
+ if (!confirm) {
579
+ log.dim('Cancelled.');
580
+ return;
581
+ }
582
+ }
583
+
584
+ // Build new paths
585
+ const newMetaPath = join(targetDir, finalMetaName);
586
+ const newContentPath = finalContentName ? join(targetDir, finalContentName) : null;
587
+
588
+ // Calculate the new Path field for metadata (relative to Bins/)
589
+ const newRelativePath = targetBin
590
+ ? `${targetBin.fullPath}/${finalContentName || displayName}`
591
+ : null;
592
+
593
+ // 1. Update metadata
594
+ const metaUpdates = { BinID: targetBinId };
595
+ if (newRelativePath) metaUpdates.Path = newRelativePath;
596
+
597
+ // Update content column references if file was renamed
598
+ if (conflict.action === 'rename' && finalContentName && meta._contentColumns) {
599
+ for (const col of meta._contentColumns) {
600
+ if (meta[col] && String(meta[col]).startsWith('@')) {
601
+ metaUpdates[col] = `@${finalContentName}`;
602
+ }
603
+ }
604
+ }
605
+
606
+ await updateMetadata(metaPath, metaUpdates, options);
607
+
608
+ // 2. Stage edit in synchronize.json
609
+ const expression = `RowID:${rowId};column:${entity}.BinID=${targetBinId}`;
610
+
611
+ await stageEdit({
612
+ UID: uid,
613
+ RowID: rowId,
614
+ entity,
615
+ name: displayName,
616
+ expression,
617
+ originalValue: currentBinId,
618
+ targetValue: targetBinId,
619
+ }, options);
620
+
621
+ // 3. Update app.json reference
622
+ await updateAppJsonPath(metaPath, newMetaPath, options);
623
+
624
+ // 4. Physically move files
625
+ await executeOperation(
626
+ () => mkdir(targetDir, { recursive: true }),
627
+ `ensure target directory ${targetDir}`,
628
+ options
629
+ );
630
+
631
+ await executeOperation(
632
+ () => rename(metaPath, newMetaPath),
633
+ `move ${metaPath} → ${newMetaPath}`,
634
+ options
635
+ );
636
+
637
+ if (contentFileName) {
638
+ const oldContentPath = join(sourceDir, contentFileName);
639
+ const contentExists = await stat(oldContentPath).catch(() => null);
640
+ if (contentExists) {
641
+ await executeOperation(
642
+ () => rename(oldContentPath, newContentPath),
643
+ `move ${oldContentPath} → ${newContentPath}`,
644
+ options
645
+ );
646
+ }
647
+ }
648
+
649
+ // Also move any media file reference
650
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
651
+ const mediaFileName = String(meta._mediaFile).substring(1);
652
+ const oldMediaPath = join(sourceDir, mediaFileName);
653
+ const newMediaPath = join(targetDir, mediaFileName);
654
+ const mediaExists = await stat(oldMediaPath).catch(() => null);
655
+ if (mediaExists) {
656
+ await executeOperation(
657
+ () => rename(oldMediaPath, newMediaPath),
658
+ `move ${oldMediaPath} → ${newMediaPath}`,
659
+ options
660
+ );
661
+ }
662
+ }
663
+
664
+ if (options.dryRun) {
665
+ log.info(`[DRY RUN] Would move "${displayName}" to "${targetBinName}" (${targetBinFullPath})`);
666
+ } else {
667
+ log.success(`Moved "${displayName}" → ${targetBinFullPath}`);
668
+ log.dim(` Staged: ${expression}`);
669
+ log.info('Run `dbo push` to apply the move on the server.');
670
+ }
671
+ }
672
+
673
+ // ── Bin move ─────────────────────────────────────────────────────────────
674
+
675
+ /**
676
+ * Recalculate fullPath for a bin and all descendants after a move.
677
+ * Returns list of updated BinIDs.
678
+ */
679
+ function recalculateBinPaths(structure, movedBinId) {
680
+ const updated = [];
681
+ const entry = structure[movedBinId];
682
+ if (!entry) return updated;
683
+
684
+ // Recalculate this bin's fullPath based on new parent
685
+ const parent = entry.parentBinID !== null ? structure[entry.parentBinID] : null;
686
+ const segment = entry.segment || entry.name;
687
+ entry.fullPath = parent ? `${parent.fullPath}/${segment}` : segment;
688
+ updated.push(movedBinId);
689
+
690
+ // Recursively update children
691
+ const children = findChildBins(movedBinId, structure);
692
+ for (const child of children) {
693
+ const childId = child.binId || Object.keys(structure).find(k => structure[k] === child);
694
+ if (childId !== undefined) {
695
+ updated.push(...recalculateBinPaths(structure, Number(childId)));
696
+ }
697
+ }
698
+
699
+ return updated;
700
+ }
701
+
702
+ /**
703
+ * Move a directory (bin) to a new parent bin.
704
+ */
705
+ async function mvBin(sourcePath, targetBinId, structure, options) {
706
+ const sourceBin = findBinByPath(sourcePath, structure);
707
+ if (!sourceBin) {
708
+ log.error(`Directory "${sourcePath}" is not a known bin in structure.json.`);
709
+ process.exit(1);
710
+ }
711
+
712
+ const sourceBinId = sourceBin.binId;
713
+ const targetBin = structure[targetBinId];
714
+
715
+ // Root-level bins are structural and cannot be moved
716
+ if (sourceBin.parentBinID === null) {
717
+ log.error(`Cannot move root-level bin "${sourceBin.name}".`);
718
+ log.dim(' Root bins are part of the DBO app structure and cannot be moved.');
719
+ log.dim(' You can move files or sub-directories within this bin instead.');
720
+ process.exit(1);
721
+ }
722
+
723
+ // No-op detection
724
+ if (sourceBin.parentBinID === targetBinId) {
725
+ log.warn(`Bin "${sourceBin.name}" is already in bin ${targetBinId}. Nothing to do.`);
726
+ return;
727
+ }
728
+
729
+ // Circular reference check
730
+ if (checkCircularReference(sourceBinId, targetBinId, structure)) {
731
+ log.error('Cannot move bin into its own subtree');
732
+ log.dim(` Source: ${sourceBin.name} (${sourceBinId})`);
733
+ log.dim(` Target: ${targetBin.name} (${targetBinId})`);
734
+ process.exit(1);
735
+ }
736
+
737
+ const targetBinName = targetBin ? targetBin.name : String(targetBinId);
738
+ const targetBinFullPath = targetBin ? `${BINS_DIR}/${targetBin.fullPath}` : null;
739
+
740
+ // Confirmation
741
+ if (!options.force && !options.dryRun) {
742
+ const inquirer = (await import('inquirer')).default;
743
+ const { confirm } = await inquirer.prompt([{
744
+ type: 'confirm',
745
+ name: 'confirm',
746
+ message: `Move bin "${sourceBin.name}" (${sourceBinId}) to "${targetBinName}" (${targetBinFullPath})?`,
747
+ default: true,
748
+ }]);
749
+ if (!confirm) {
750
+ log.dim('Cancelled.');
751
+ return;
752
+ }
753
+ }
754
+
755
+ // Calculate old and new paths
756
+ const oldDirPath = `${BINS_DIR}/${sourceBin.fullPath}`;
757
+ const oldFullPath = sourceBin.fullPath;
758
+
759
+ // 1. Update bin's ParentBinID in structure
760
+ if (!options.dryRun) {
761
+ structure[sourceBinId].parentBinID = targetBinId;
762
+ }
763
+
764
+ // 2. Recalculate paths for moved bin and descendants
765
+ const oldPaths = {};
766
+ // Capture old paths before recalculation
767
+ const collectOldPaths = (binId) => {
768
+ const e = structure[binId];
769
+ if (e) oldPaths[binId] = e.fullPath;
770
+ const children = findChildBins(binId, structure);
771
+ for (const child of children) {
772
+ const cid = child.binId || Object.keys(structure).find(k => structure[k] === child);
773
+ if (cid !== undefined) collectOldPaths(Number(cid));
774
+ }
775
+ };
776
+ collectOldPaths(sourceBinId);
777
+
778
+ const updatedBinIds = options.dryRun ? [] : recalculateBinPaths(structure, sourceBinId);
779
+ const newDirPath = options.dryRun
780
+ ? `${BINS_DIR}/${targetBin.fullPath}/${sourceBin.segment || sourceBin.name}`
781
+ : `${BINS_DIR}/${structure[sourceBinId].fullPath}`;
782
+
783
+ // 3. Update metadata for the bin itself (ParentBinID)
784
+ // Look for a bin metadata file in the source directory
785
+ const binMetaPath = join(oldDirPath, `${sourceBin.name}.metadata.json`);
786
+ const binMetaExists = await stat(binMetaPath).catch(() => null);
787
+ if (binMetaExists) {
788
+ await updateMetadata(binMetaPath, { ParentBinID: targetBinId }, options);
789
+ }
790
+
791
+ // 4. Stage edit for bin ParentBinID
792
+ const currentParentBinId = sourceBin.parentBinID;
793
+ const expression = `RowID:${sourceBinId};column:bin.ParentBinID=${targetBinId}`;
794
+
795
+ await stageEdit({
796
+ UID: sourceBin.uid,
797
+ RowID: sourceBinId,
798
+ entity: 'bin',
799
+ name: sourceBin.name,
800
+ expression,
801
+ originalValue: currentParentBinId,
802
+ targetValue: targetBinId,
803
+ }, options);
804
+
805
+ // 5. Update app.json for all affected file paths
806
+ await updateAppJsonForDirectoryMove(oldDirPath, newDirPath, options);
807
+
808
+ // 6. Save updated structure.json
809
+ await executeOperation(
810
+ () => saveStructureFile(structure),
811
+ 'save updated structure.json',
812
+ options
813
+ );
814
+
815
+ // 7. Physically move directory
816
+ await executeOperation(
817
+ async () => {
818
+ const parentDir = dirname(newDirPath);
819
+ await mkdir(parentDir, { recursive: true });
820
+ await rename(oldDirPath, newDirPath);
821
+ },
822
+ `move ${oldDirPath} → ${newDirPath}`,
823
+ options
824
+ );
825
+
826
+ if (options.dryRun) {
827
+ log.info(`[DRY RUN] Would move bin "${sourceBin.name}" to "${targetBinName}"`);
828
+ log.dim(` ${oldDirPath} → ${newDirPath}`);
829
+ if (Object.keys(oldPaths).length > 1) {
830
+ log.dim(` ${Object.keys(oldPaths).length} bins would have paths recalculated`);
831
+ }
832
+ } else {
833
+ log.success(`Moved bin "${sourceBin.name}" → ${targetBinFullPath}`);
834
+ log.dim(` Staged: ${expression}`);
835
+ if (updatedBinIds.length > 1) {
836
+ log.dim(` Updated paths for ${updatedBinIds.length} bins`);
837
+ }
838
+ log.info('Run `dbo push` to apply the move on the server.');
839
+ }
840
+ }
841
+
842
+ // ── Exports for testing ──────────────────────────────────────────────────
843
+
844
+ /**
845
+ * Check if a source path is inside the Bins/ directory.
846
+ */
847
+ function isInsideBins(sourcePath) {
848
+ const rel = isAbsolute(sourcePath) ? relative(process.cwd(), sourcePath) : sourcePath;
849
+ const normalized = rel.replace(/\/+$/, '');
850
+ return normalized.startsWith(`${BINS_DIR}/`);
851
+ }
852
+
853
+ /**
854
+ * Check if a bin is a root-level bin (cannot be moved).
855
+ */
856
+ function isRootBin(binEntry) {
857
+ return !!binEntry && binEntry.parentBinID === null;
858
+ }
859
+
860
+ export {
861
+ resolveBinId,
862
+ checkCircularReference,
863
+ mergeExpressions,
864
+ resolveMetaPath,
865
+ recalculateBinPaths,
866
+ extractColumnKey,
867
+ isInsideBins,
868
+ isRootBin,
869
+ };