@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.
- package/README.md +268 -87
- package/bin/dbo.js +7 -3
- package/package.json +27 -4
- package/src/commands/clone.js +469 -14
- package/src/commands/diff.js +246 -0
- package/src/commands/init.js +31 -23
- package/src/commands/install.js +526 -69
- package/src/commands/mv.js +869 -0
- package/src/commands/pull.js +6 -0
- package/src/commands/push.js +63 -21
- package/src/commands/rm.js +337 -0
- package/src/commands/status.js +28 -1
- package/src/lib/config.js +195 -0
- package/src/lib/diff.js +740 -0
- package/src/lib/save-to-disk.js +71 -4
- package/src/lib/structure.js +36 -0
- package/src/plugins/claudecommands/dbo.md +38 -7
- package/src/commands/update.js +0 -168
|
@@ -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
|
+
};
|