@dboio/cli 0.6.7 → 0.6.9

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 CHANGED
@@ -253,6 +253,34 @@ dbo init --domain my-domain.com --app myapp --clone
253
253
 
254
254
  When cloning an app that was already cloned locally, the CLI detects existing files and compares modification times against the server's `_LastUpdated`. You'll be prompted to overwrite, compare differences, or skip — same as `dbo pull`. Use `-y` to auto-accept all changes.
255
255
 
256
+ #### Collision detection
257
+
258
+ When multiple records would create files at the same path (e.g., a `content` record and a `media` record both named `colors.css`), the CLI detects the collision before writing any files and prompts you to choose which record to keep:
259
+
260
+ ```
261
+ ⚠ Collision: 2 records want to create "Bins/app/colors.css"
262
+ ? Which record should create this file?
263
+ ❯ [content] colors (UID: abc123)
264
+ [media] colors.css (UID: def456)
265
+ ```
266
+
267
+ The rejected record is automatically staged for deletion in `.dbo/synchronize.json`. Run `dbo push` to delete it from the server.
268
+
269
+ In non-interactive mode (`-y`), the first record is kept and others are auto-staged for deletion.
270
+
271
+ #### Stale media cleanup
272
+
273
+ During media downloads, files returning 404 (no longer exist on the server) are collected as "stale records". After all downloads complete, you'll be prompted to stage them for deletion:
274
+
275
+ ```
276
+ Found 3 stale media record(s) (404 - files no longer exist on server)
277
+ ? Stage these 3 stale media records for deletion? (y/N)
278
+ ```
279
+
280
+ This helps keep your app clean by removing database records for media files that have been deleted from the server.
281
+
282
+ In non-interactive mode (`-y`), stale cleanup is skipped (conservative default).
283
+
256
284
  #### Path resolution
257
285
 
258
286
  When a content record has both `Path` and `BinID`, the CLI prompts:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbo",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "DBO.io CLI integration for Claude Code",
5
5
  "author": {
6
6
  "name": "DBO.io"
@@ -8,6 +8,7 @@ import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
9
  import { loadAppConfig } from '../lib/config.js';
10
10
  import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
11
12
 
12
13
  // Directories and patterns to skip when scanning with `dbo add .`
13
14
  const IGNORE_DIRS = new Set(['.dbo', '.git', 'node_modules', '.svn', '.hg']);
@@ -17,6 +18,7 @@ export const addCommand = new Command('add')
17
18
  .argument('<path>', 'File or "." to scan current directory')
18
19
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
19
20
  .option('--ticket <id>', 'Override ticket ID')
21
+ .option('--modifyKey <key>', 'Provide ModifyKey directly (skips interactive prompt)')
20
22
  .option('-y, --yes', 'Auto-accept all prompts')
21
23
  .option('--json', 'Output raw JSON')
22
24
  .option('--jq <expr>', 'Filter JSON response')
@@ -26,6 +28,14 @@ export const addCommand = new Command('add')
26
28
  try {
27
29
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
28
30
 
31
+ // ModifyKey guard
32
+ const modifyKeyResult = await checkModifyKey(options);
33
+ if (modifyKeyResult.cancel) {
34
+ log.info('Submission cancelled');
35
+ return;
36
+ }
37
+ if (modifyKeyResult.modifyKey) options._resolvedModifyKey = modifyKeyResult.modifyKey;
38
+
29
39
  if (targetPath === '.') {
30
40
  await addDirectory(process.cwd(), client, options);
31
41
  } else {
@@ -242,10 +252,21 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
242
252
 
243
253
  const extraParams = { '_confirm': options.confirm };
244
254
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
255
+ if (options._resolvedModifyKey) extraParams['_modify_key'] = options._resolvedModifyKey;
245
256
 
246
257
  let body = await buildInputBody(dataExprs, extraParams);
247
258
  let result = await client.postUrlEncoded('/api/input/submit', body);
248
259
 
260
+ // Reactive ModifyKey retry
261
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
262
+ const retryMK = await handleModifyKeyError();
263
+ if (retryMK.cancel) { log.info('Submission cancelled'); return null; }
264
+ extraParams['_modify_key'] = retryMK.modifyKey;
265
+ options._resolvedModifyKey = retryMK.modifyKey;
266
+ body = await buildInputBody(dataExprs, extraParams);
267
+ result = await client.postUrlEncoded('/api/input/submit', body);
268
+ }
269
+
249
270
  // Retry with prompted params if needed (ticket, user, repo mismatch)
250
271
  const retryResult = await checkSubmitErrors(result);
251
272
  if (retryResult) {
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { readFile, writeFile, mkdir, access } from 'fs/promises';
3
3
  import { join, basename, extname } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline } from '../lib/config.js';
5
+ import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey } from '../lib/config.js';
6
6
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { setFileTimestamps } from '../lib/timestamps.js';
@@ -80,6 +80,342 @@ function resolvePathToBinsDir(pathValue, structure) {
80
80
  return `${BINS_DIR}/${dirPart}`;
81
81
  }
82
82
 
83
+ /**
84
+ * Extract path components for content/generic records (read-only, no file writes).
85
+ * Replicates logic from processRecord() for collision detection.
86
+ */
87
+ function resolveRecordPaths(entityName, record, structure, placementPref) {
88
+ let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
89
+
90
+ // Determine extension (priority: Extension field > Name > Path)
91
+ let ext = '';
92
+ if (record.Extension) {
93
+ ext = String(record.Extension).toLowerCase();
94
+ } else {
95
+ if (record.Name) {
96
+ const extractedExt = extname(String(record.Name));
97
+ ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
98
+ }
99
+ if (!ext && record.Path) {
100
+ const extractedExt = extname(String(record.Path));
101
+ ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
102
+ }
103
+ }
104
+
105
+ // Strip double extension
106
+ if (ext) {
107
+ const extWithDot = `.${ext}`;
108
+ if (name.toLowerCase().endsWith(extWithDot)) {
109
+ name = name.substring(0, name.length - extWithDot.length);
110
+ }
111
+ }
112
+
113
+ // Resolve directory (BinID vs Path)
114
+ let dir = BINS_DIR;
115
+ const hasBinID = record.BinID && structure[record.BinID];
116
+ const hasPath = record.Path && typeof record.Path === 'string' && record.Path.trim();
117
+
118
+ if (hasBinID && hasPath) {
119
+ dir = placementPref === 'path' ? record.Path : resolveBinPath(record.BinID, structure);
120
+ } else if (hasBinID) {
121
+ dir = resolveBinPath(record.BinID, structure);
122
+ } else if (hasPath) {
123
+ dir = resolvePathToBinsDir(record.Path, structure);
124
+ }
125
+
126
+ // Clean directory path
127
+ dir = dir.replace(/^\/+|\/+$/g, '');
128
+ if (!dir) dir = BINS_DIR;
129
+ const pathExt = extname(dir);
130
+ if (pathExt) {
131
+ dir = dir.substring(0, dir.lastIndexOf('/')) || BINS_DIR;
132
+ }
133
+
134
+ const filename = ext ? `${name}.${ext}` : name;
135
+ const metaPath = join(dir, `${name}.metadata.json`);
136
+
137
+ return { dir, filename, metaPath };
138
+ }
139
+
140
+ /**
141
+ * Extract path components for media records.
142
+ * Replicates logic from processMediaEntries() for collision detection.
143
+ */
144
+ function resolveMediaPaths(record, structure, placementPref) {
145
+ const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
146
+ const name = sanitizeFilename(filename.replace(/\.[^.]+$/, ''));
147
+ const ext = (record.Extension || 'bin').toLowerCase();
148
+
149
+ let dir = BINS_DIR;
150
+ const hasBinID = record.BinID && structure[record.BinID];
151
+ const hasFullPath = record.FullPath && typeof record.FullPath === 'string';
152
+
153
+ let fullPathDir = null;
154
+ if (hasFullPath) {
155
+ const stripped = record.FullPath.replace(/^\/+/, '');
156
+ const lastSlash = stripped.lastIndexOf('/');
157
+ fullPathDir = lastSlash >= 0 ? stripped.substring(0, lastSlash) : BINS_DIR;
158
+ }
159
+
160
+ if (hasBinID && fullPathDir && fullPathDir !== '.') {
161
+ dir = placementPref === 'path' ? fullPathDir : resolveBinPath(record.BinID, structure);
162
+ } else if (hasBinID) {
163
+ dir = resolveBinPath(record.BinID, structure);
164
+ } else if (fullPathDir && fullPathDir !== BINS_DIR) {
165
+ dir = fullPathDir;
166
+ }
167
+
168
+ dir = dir.replace(/^\/+|\/+$/g, '');
169
+ if (!dir) dir = BINS_DIR;
170
+
171
+ const finalFilename = `${name}.${ext}`;
172
+ const metaPath = join(dir, `${finalFilename}.metadata.json`);
173
+
174
+ return { dir, filename: finalFilename, metaPath };
175
+ }
176
+
177
+ /**
178
+ * Extract path components for entity-dir records.
179
+ * Simplified from processEntityDirEntries() for collision detection.
180
+ */
181
+ function resolveEntityDirPaths(entityName, record, dirName) {
182
+ let name;
183
+ if (entityName === 'app_version' && record.Number) {
184
+ name = sanitizeFilename(String(record.Number));
185
+ } else if (record.Name) {
186
+ name = sanitizeFilename(String(record.Name));
187
+ } else {
188
+ name = sanitizeFilename(String(record.UID || 'untitled'));
189
+ }
190
+
191
+ const uid = record.UID || 'untitled';
192
+ const finalName = name === uid ? uid : `${name}.${uid}`;
193
+ const metaPath = join(dirName, `${finalName}.metadata.json`);
194
+ return { dir: dirName, filename: finalName, metaPath };
195
+ }
196
+
197
+ /**
198
+ * Build global registry of all files that will be created during clone.
199
+ * Returns Map<filePath, Array<{entity, record, dir, filename, metaPath}>>
200
+ */
201
+ async function buildFileRegistry(appJson, structure, placementPrefs) {
202
+ const registry = new Map();
203
+
204
+ function addToRegistry(filePath, entity, record, dir, filename, metaPath) {
205
+ if (!registry.has(filePath)) {
206
+ registry.set(filePath, []);
207
+ }
208
+ registry.get(filePath).push({ entity, record, dir, filename, metaPath });
209
+ }
210
+
211
+ // Process content records
212
+ for (const record of (appJson.children.content || [])) {
213
+ const { dir, filename, metaPath } = resolveRecordPaths(
214
+ 'content', record, structure, placementPrefs.contentPlacement
215
+ );
216
+ addToRegistry(join(dir, filename), 'content', record, dir, filename, metaPath);
217
+ }
218
+
219
+ // Process media records
220
+ for (const record of (appJson.children.media || [])) {
221
+ const { dir, filename, metaPath } = resolveMediaPaths(
222
+ record, structure, placementPrefs.mediaPlacement
223
+ );
224
+ addToRegistry(join(dir, filename), 'media', record, dir, filename, metaPath);
225
+ }
226
+
227
+ // Note: entity-dir records (Extensions/, Data Sources/, etc.) and generic entities
228
+ // are excluded from collision detection — they use UID-based naming to handle duplicates.
229
+ // Only content and media records in Bins/ are checked for cross-entity collisions.
230
+
231
+ return registry;
232
+ }
233
+
234
+ /**
235
+ * Detect collisions and prompt user to choose which record to keep.
236
+ * Uses stored resolutions from config and pending deletes from synchronize.json
237
+ * to avoid re-prompting on subsequent clones.
238
+ * Returns Set of UIDs to skip during processing.
239
+ */
240
+ async function resolveCollisions(registry, options) {
241
+ const toDelete = new Set();
242
+ const savedResolutions = await loadCollisionResolutions();
243
+ const sync = await loadSynchronize();
244
+ const pendingDeleteUIDs = new Set((sync.delete || []).map(e => e.UID));
245
+ const newResolutions = { ...savedResolutions };
246
+
247
+ // Find all collisions (paths with >1 record)
248
+ const collisions = [];
249
+ for (const [filePath, records] of registry.entries()) {
250
+ if (records.length > 1) {
251
+ collisions.push({ filePath, records });
252
+ }
253
+ }
254
+
255
+ if (collisions.length === 0) return toDelete;
256
+
257
+ log.warn(`Found ${collisions.length} file path collision(s)`);
258
+
259
+ for (const { filePath, records } of collisions) {
260
+ // Same-entity duplicates are not cross-entity collisions — handled by UID naming
261
+ const entities = new Set(records.map(r => r.entity));
262
+ if (entities.size === 1) {
263
+ log.dim(` Same-entity duplicates for "${filePath}" — will use UID naming`);
264
+ continue;
265
+ }
266
+
267
+ // Check if we have a stored resolution for this path
268
+ const saved = savedResolutions[filePath];
269
+ if (saved && records.some(r => r.record.UID === saved.keepUID)) {
270
+ // Auto-apply stored resolution
271
+ for (const r of records) {
272
+ if (r.record.UID !== saved.keepUID) {
273
+ toDelete.add(r.record.UID);
274
+ }
275
+ }
276
+ log.dim(` Collision "${filePath}" → keeping ${saved.keepEntity} (saved preference)`);
277
+ continue;
278
+ }
279
+
280
+ // Check if any record in this collision is already pending deletion
281
+ const pendingRecord = records.find(r => pendingDeleteUIDs.has(r.record.UID));
282
+ if (pendingRecord) {
283
+ toDelete.add(pendingRecord.record.UID);
284
+ // Store as resolution for future clones
285
+ const keptRecord = records.find(r => r.record.UID !== pendingRecord.record.UID);
286
+ if (keptRecord) {
287
+ newResolutions[filePath] = { keepUID: keptRecord.record.UID, keepEntity: keptRecord.entity };
288
+ }
289
+ log.dim(` Collision "${filePath}" → ${pendingRecord.record.UID} already staged for deletion`);
290
+ continue;
291
+ }
292
+
293
+ // Non-interactive mode: keep first, skip rest
294
+ if (options.yes) {
295
+ for (let i = 1; i < records.length; i++) {
296
+ toDelete.add(records[i].record.UID);
297
+ }
298
+ newResolutions[filePath] = { keepUID: records[0].record.UID, keepEntity: records[0].entity };
299
+ continue;
300
+ }
301
+
302
+ // Interactive resolution
303
+ const inquirer = (await import('inquirer')).default;
304
+
305
+ log.plain('');
306
+ log.warn(`Collision: ${records.length} records want to create "${filePath}"`);
307
+
308
+ const choices = records.map((r, idx) => {
309
+ const label = r.record.Name || r.record.Filename || r.record.UID;
310
+ return {
311
+ name: `[${r.entity}] ${label} (UID: ${r.record.UID})`,
312
+ value: idx,
313
+ };
314
+ });
315
+
316
+ const { keepIdx } = await inquirer.prompt([{
317
+ type: 'list',
318
+ name: 'keepIdx',
319
+ message: 'Which record should create this file?',
320
+ choices,
321
+ }]);
322
+
323
+ // Store resolution for future clones
324
+ newResolutions[filePath] = { keepUID: records[keepIdx].record.UID, keepEntity: records[keepIdx].entity };
325
+
326
+ // Mark others for deletion
327
+ for (let i = 0; i < records.length; i++) {
328
+ if (i !== keepIdx) {
329
+ toDelete.add(records[i].record.UID);
330
+ }
331
+ }
332
+ }
333
+
334
+ // Save resolutions for future clones
335
+ await saveCollisionResolutions(newResolutions);
336
+
337
+ return toDelete;
338
+ }
339
+
340
+ /**
341
+ * Stage deletion entries for rejected collision records.
342
+ * Prompts per-item for confirmation unless -y flag is set.
343
+ */
344
+ async function stageCollisionDeletions(toDelete, appJson, options) {
345
+ if (toDelete.size === 0) return;
346
+
347
+ // Collect records to potentially stage
348
+ const toStage = [];
349
+ for (const [entityName, entries] of Object.entries(appJson.children)) {
350
+ if (!Array.isArray(entries)) continue;
351
+
352
+ for (const record of entries) {
353
+ if (!toDelete.has(record.UID)) continue;
354
+
355
+ // Find RowID
356
+ let rowId = record._id;
357
+ if (!rowId) {
358
+ const entityCap = entityName.charAt(0).toUpperCase() + entityName.slice(1);
359
+ rowId = record[`${entityCap}ID`];
360
+ }
361
+
362
+ if (!rowId) {
363
+ log.warn(`Cannot stage ${entityName} ${record.UID}: no RowID`);
364
+ continue;
365
+ }
366
+
367
+ const name = record.Name || record.Filename || record.UID;
368
+ toStage.push({ UID: record.UID, RowID: rowId, entity: entityName, name });
369
+ }
370
+ }
371
+
372
+ if (toStage.length === 0) return;
373
+
374
+ // Check which are already staged (skip re-prompting)
375
+ const sync = await loadSynchronize();
376
+ const alreadyStaged = new Set((sync.delete || []).map(e => e.UID));
377
+ const newItems = toStage.filter(item => !alreadyStaged.has(item.UID));
378
+
379
+ if (newItems.length === 0) {
380
+ log.dim(` All ${toStage.length} collision rejection(s) already staged for deletion`);
381
+ return;
382
+ }
383
+
384
+ // Non-interactive mode: skip staging
385
+ if (options.yes) {
386
+ log.info(`Non-interactive mode: skipping deletion staging for ${newItems.length} rejected record(s)`);
387
+ return;
388
+ }
389
+
390
+ // Prompt per-item
391
+ log.info(`${newItems.length} rejected record(s) can be staged for deletion:`);
392
+ const inquirer = (await import('inquirer')).default;
393
+ let staged = 0;
394
+
395
+ for (const item of newItems) {
396
+ const { stage } = await inquirer.prompt([{
397
+ type: 'confirm',
398
+ name: 'stage',
399
+ message: `Stage [${item.entity}] "${item.name}" (UID: ${item.UID}) for deletion?`,
400
+ default: true,
401
+ }]);
402
+
403
+ if (stage) {
404
+ const expression = `RowID:del${item.RowID};entity:${item.entity}=true`;
405
+ await addDeleteEntry({ UID: item.UID, RowID: item.RowID, entity: item.entity, name: item.name, expression });
406
+ log.dim(` Staged: ${item.entity} "${item.name}"`);
407
+ staged++;
408
+ } else {
409
+ log.dim(` Skipped: ${item.entity} "${item.name}"`);
410
+ }
411
+ }
412
+
413
+ if (staged > 0) {
414
+ log.success(`${staged} record(s) staged in .dbo/synchronize.json`);
415
+ log.dim(' Run "dbo push" to delete from server');
416
+ }
417
+ }
418
+
83
419
  export const cloneCommand = new Command('clone')
84
420
  .description('Clone an app from DBO.io to a local project structure')
85
421
  .argument('[source]', 'Local JSON file path (optional)')
@@ -165,6 +501,16 @@ export async function performClone(source, options = {}) {
165
501
  });
166
502
  log.dim(' Updated .dbo/config.json with app metadata');
167
503
 
504
+ // Detect and store ModifyKey for locked/production apps
505
+ const modifyKey = appJson.ModifyKey || null;
506
+ await saveAppModifyKey(modifyKey);
507
+ if (modifyKey) {
508
+ log.warn('');
509
+ log.warn(' ⚠ This app has a ModifyKey set (production/locked mode).');
510
+ log.warn(' You will be prompted to enter the ModifyKey before any push, input, add, content deploy, or deploy command.');
511
+ log.warn('');
512
+ }
513
+
168
514
  // Step 3: Update package.json
169
515
  await updatePackageJson(appJson, config);
170
516
 
@@ -194,20 +540,30 @@ export async function performClone(source, options = {}) {
194
540
  log.dim(` Set ServerTimezone to ${serverTz} in .dbo/config.json`);
195
541
  }
196
542
 
197
- // Step 5: Process content files + metadata
543
+ // Step 4c: Detect and resolve file path collisions
544
+ log.info('Scanning for file path collisions...');
545
+ const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
546
+ const toDeleteUIDs = await resolveCollisions(fileRegistry, options);
547
+
548
+ if (toDeleteUIDs.size > 0) {
549
+ await stageCollisionDeletions(toDeleteUIDs, appJson, options);
550
+ }
551
+
552
+ // Step 5: Process content → files + metadata (skip rejected records)
198
553
  const contentRefs = await processContentEntries(
199
554
  appJson.children.content || [],
200
555
  structure,
201
556
  options,
202
557
  placementPrefs.contentPlacement,
203
558
  serverTz,
559
+ toDeleteUIDs,
204
560
  );
205
561
 
206
- // Step 5b: Process media → download binary files + metadata
562
+ // Step 5b: Process media → download binary files + metadata (skip rejected records)
207
563
  let mediaRefs = [];
208
564
  const mediaEntries = appJson.children.media || [];
209
565
  if (mediaEntries.length > 0) {
210
- mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, placementPrefs.mediaPlacement, serverTz);
566
+ mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, placementPrefs.mediaPlacement, serverTz, toDeleteUIDs);
211
567
  }
212
568
 
213
569
  // Step 6: Process other entities (not output, not bin, not content, not media)
@@ -396,7 +752,7 @@ async function updatePackageJson(appJson, config) {
396
752
  * Process content entries: write files + metadata, return reference map.
397
753
  * Returns array of { uid, metaPath } for app.json reference replacement.
398
754
  */
399
- async function processContentEntries(contents, structure, options, contentPlacement, serverTz) {
755
+ async function processContentEntries(contents, structure, options, contentPlacement, serverTz, skipUIDs = new Set()) {
400
756
  if (!contents || contents.length === 0) return [];
401
757
 
402
758
  const refs = [];
@@ -410,6 +766,10 @@ async function processContentEntries(contents, structure, options, contentPlacem
410
766
  log.info(`Processing ${contents.length} content record(s)...`);
411
767
 
412
768
  for (const record of contents) {
769
+ if (skipUIDs.has(record.UID)) {
770
+ log.dim(` Skipped ${record.Name || record.UID} (collision rejection)`);
771
+ continue;
772
+ }
413
773
  const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
414
774
  if (ref) refs.push(ref);
415
775
  }
@@ -465,7 +825,6 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
465
825
  await mkdir(dirName, { recursive: true });
466
826
 
467
827
  const refs = [];
468
- const usedNames = new Map();
469
828
  const bulkAction = { value: null };
470
829
  const config = await loadConfig();
471
830
 
@@ -614,11 +973,10 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
614
973
  name = sanitizeFilename(String(record.UID || 'untitled'));
615
974
  }
616
975
 
617
- // Deduplicate filenames
618
- const nameKey = `${dirName}/${name}`;
619
- const count = usedNames.get(nameKey) || 0;
620
- usedNames.set(nameKey, count + 1);
621
- const finalName = count > 0 ? `${name}-${count + 1}` : name;
976
+ // Include UID in filename to ensure uniqueness (multiple records can share the same name)
977
+ // Convention: <name>.<UID>.metadata.json — unless name === UID, then just <UID>.metadata.json
978
+ const uid = record.UID || 'untitled';
979
+ const finalName = name === uid ? uid : `${name}.${uid}`;
622
980
 
623
981
  const metaPath = join(dirName, `${finalName}.metadata.json`);
624
982
 
@@ -755,9 +1113,12 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
755
1113
  * Process media entries: download binary files from server + create metadata.
756
1114
  * Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
757
1115
  */
758
- async function processMediaEntries(mediaRecords, structure, options, config, appShortName, mediaPlacement, serverTz) {
1116
+ async function processMediaEntries(mediaRecords, structure, options, config, appShortName, mediaPlacement, serverTz, skipUIDs = new Set()) {
759
1117
  if (!mediaRecords || mediaRecords.length === 0) return [];
760
1118
 
1119
+ // Track stale records (404s) for cleanup prompt
1120
+ const staleRecords = [];
1121
+
761
1122
  // Determine if we can download (need a server connection)
762
1123
  let canDownload = false;
763
1124
  let client = null;
@@ -804,6 +1165,12 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
804
1165
 
805
1166
  for (const record of mediaRecords) {
806
1167
  const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
1168
+
1169
+ if (skipUIDs.has(record.UID)) {
1170
+ log.dim(` Skipped ${filename} (collision rejection)`);
1171
+ continue;
1172
+ }
1173
+
807
1174
  const name = sanitizeFilename(filename.replace(/\.[^.]+$/, '')); // base name without extension
808
1175
  const ext = (record.Extension || 'bin').toLowerCase();
809
1176
 
@@ -869,7 +1236,14 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
869
1236
  const fileKey = `${dir}/${name}.${ext}`;
870
1237
  const fileCount = usedNames.get(fileKey) || 0;
871
1238
  usedNames.set(fileKey, fileCount + 1);
872
- const dedupName = fileCount > 0 ? `${name}-${fileCount + 1}` : name;
1239
+ let dedupName;
1240
+ if (fileCount > 0) {
1241
+ // Duplicate name — include UID for uniqueness
1242
+ const uid = record.UID || 'untitled';
1243
+ dedupName = name === uid ? uid : `${name}.${uid}`;
1244
+ } else {
1245
+ dedupName = name;
1246
+ }
873
1247
  const finalFilename = `${dedupName}.${ext}`;
874
1248
  // Metadata: use name.ext as base to avoid collisions between formats
875
1249
  // e.g. KaTeX_SansSerif-Italic.woff.metadata.json vs KaTeX_SansSerif-Italic.woff2.metadata.json
@@ -954,11 +1328,26 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
954
1328
  log.success(`Downloaded ${filePath} (${sizeKB} KB)`);
955
1329
  downloaded++;
956
1330
  } catch (err) {
957
- log.warn(`Failed to download ${filename}`);
958
- log.dim(` UID: ${record.UID}`);
959
- log.dim(` URL: /api/media/${record.UID}`);
960
- if (record.FullPath) log.dim(` FullPath: ${record.FullPath}`);
961
- log.dim(` Error: ${err.message}`);
1331
+ // Check if error is 404 (stale record)
1332
+ const is404 = /Download failed: 404/.test(err.message);
1333
+
1334
+ if (is404) {
1335
+ log.warn(`Stale media: ${filename} (404 - file no longer exists)`);
1336
+ staleRecords.push({
1337
+ UID: record.UID,
1338
+ RowID: record._id || record.MediaID,
1339
+ filename,
1340
+ fullPath: record.FullPath,
1341
+ record,
1342
+ });
1343
+ } else {
1344
+ log.warn(`Failed to download ${filename}`);
1345
+ log.dim(` UID: ${record.UID}`);
1346
+ log.dim(` URL: /api/media/${record.UID}`);
1347
+ if (record.FullPath) log.dim(` FullPath: ${record.FullPath}`);
1348
+ log.dim(` Error: ${err.message}`);
1349
+ }
1350
+
962
1351
  failed++;
963
1352
  continue; // Skip metadata if download failed
964
1353
  }
@@ -987,6 +1376,41 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
987
1376
  }
988
1377
 
989
1378
  log.info(`Media: ${downloaded} downloaded, ${failed} failed`);
1379
+
1380
+ // Prompt for stale record cleanup
1381
+ if (staleRecords.length > 0 && !options.yes) {
1382
+ log.plain('');
1383
+ log.info(`Found ${staleRecords.length} stale media record(s) (404 - files no longer exist on server)`);
1384
+
1385
+ const inquirer = (await import('inquirer')).default;
1386
+ const { cleanup } = await inquirer.prompt([{
1387
+ type: 'confirm',
1388
+ name: 'cleanup',
1389
+ message: `Stage these ${staleRecords.length} stale media records for deletion?`,
1390
+ default: false, // Conservative default
1391
+ }]);
1392
+
1393
+ if (cleanup) {
1394
+ for (const stale of staleRecords) {
1395
+ const expression = `RowID:del${stale.RowID};entity:media=true`;
1396
+ await addDeleteEntry({
1397
+ UID: stale.UID,
1398
+ RowID: stale.RowID,
1399
+ entity: 'media',
1400
+ name: stale.filename,
1401
+ expression,
1402
+ });
1403
+ log.dim(` Staged: ${stale.filename}`);
1404
+ }
1405
+ log.success('Stale media records staged in .dbo/synchronize.json');
1406
+ log.dim(' Run "dbo push" to delete from server');
1407
+ } else {
1408
+ log.info('Skipped stale media cleanup');
1409
+ }
1410
+ } else if (staleRecords.length > 0 && options.yes) {
1411
+ log.info(`Non-interactive mode: skipping stale cleanup for ${staleRecords.length} record(s)`);
1412
+ }
1413
+
990
1414
  return refs;
991
1415
  }
992
1416
 
@@ -1084,11 +1508,18 @@ async function processRecord(entityName, record, structure, options, usedNames,
1084
1508
 
1085
1509
  await mkdir(dir, { recursive: true });
1086
1510
 
1087
- // Deduplicate filenames
1511
+ // Deduplicate filenames — use UID naming when duplicates exist
1088
1512
  const nameKey = `${dir}/${name}`;
1089
1513
  const count = usedNames.get(nameKey) || 0;
1090
1514
  usedNames.set(nameKey, count + 1);
1091
- const finalName = count > 0 ? `${name}-${count + 1}` : name;
1515
+ let finalName;
1516
+ if (count > 0) {
1517
+ // Duplicate name — include UID for uniqueness
1518
+ const uid = record.UID || 'untitled';
1519
+ finalName = name === uid ? uid : `${name}.${uid}`;
1520
+ } else {
1521
+ finalName = name;
1522
+ }
1092
1523
 
1093
1524
  // Write content file if Content column has data
1094
1525
  const contentValue = record.Content;
@@ -1307,8 +1738,8 @@ function decodeBase64Fields(obj) {
1307
1738
  // Process each property
1308
1739
  for (const [key, value] of Object.entries(obj)) {
1309
1740
  if (value && typeof value === 'object') {
1310
- // Check if it's a base64 encoded value
1311
- if (!Array.isArray(value) && value.encoding === 'base64' && typeof value.value === 'string') {
1741
+ // Check if it's a base64 encoded value (handles both value: string and value: null)
1742
+ if (!Array.isArray(value) && value.encoding === 'base64') {
1312
1743
  // Decode using existing resolveContentValue function
1313
1744
  obj[key] = resolveContentValue(value);
1314
1745
  } else {
@@ -5,6 +5,7 @@ import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { saveToDisk } from '../lib/save-to-disk.js';
8
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
8
9
  import { log } from '../lib/logger.js';
9
10
 
10
11
  function collect(value, previous) {
@@ -28,16 +29,38 @@ const deployCmd = new Command('deploy')
28
29
  .argument('<filepath>', 'Local file path')
29
30
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
30
31
  .option('--ticket <id>', 'Override ticket ID')
32
+ .option('--modifyKey <key>', 'Provide ModifyKey directly (skips interactive prompt)')
31
33
  .option('--json', 'Output raw JSON')
32
34
  .option('-v, --verbose', 'Show HTTP request details')
33
35
  .option('--domain <host>', 'Override domain')
34
36
  .action(async (uid, filepath, options) => {
35
37
  try {
36
38
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
39
+
40
+ // ModifyKey guard
41
+ const modifyKeyResult = await checkModifyKey(options);
42
+ if (modifyKeyResult.cancel) {
43
+ log.info('Submission cancelled');
44
+ return;
45
+ }
46
+
37
47
  const extraParams = { '_confirm': options.confirm };
38
48
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
39
- const body = await buildInputBody([`RowUID:${uid};column:content.Content@${filepath}`], extraParams);
40
- const result = await client.postUrlEncoded('/api/input/submit', body);
49
+ if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
50
+
51
+ const dataExprs = [`RowUID:${uid};column:content.Content@${filepath}`];
52
+ let body = await buildInputBody(dataExprs, extraParams);
53
+ let result = await client.postUrlEncoded('/api/input/submit', body);
54
+
55
+ // Reactive ModifyKey retry
56
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
57
+ const retryMK = await handleModifyKeyError();
58
+ if (retryMK.cancel) { log.info('Submission cancelled'); return; }
59
+ extraParams['_modify_key'] = retryMK.modifyKey;
60
+ body = await buildInputBody(dataExprs, extraParams);
61
+ result = await client.postUrlEncoded('/api/input/submit', body);
62
+ }
63
+
41
64
  formatResponse(result, { json: options.json });
42
65
  if (!result.successful) process.exit(1);
43
66
  } catch (err) {
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises';
3
3
  import { DboClient } from '../lib/client.js';
4
4
  import { buildInputBody } from '../lib/input-parser.js';
5
5
  import { formatResponse, formatError } from '../lib/formatter.js';
6
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
6
7
  import { log } from '../lib/logger.js';
7
8
 
8
9
  const MANIFEST_FILE = 'dbo.deploy.json';
@@ -13,6 +14,7 @@ export const deployCommand = new Command('deploy')
13
14
  .option('--all', 'Deploy all entries in the manifest')
14
15
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
15
16
  .option('--ticket <id>', 'Override ticket ID')
17
+ .option('--modifyKey <key>', 'Provide ModifyKey directly (skips interactive prompt)')
16
18
  .option('--json', 'Output raw JSON')
17
19
  .option('-v, --verbose', 'Show HTTP request details')
18
20
  .option('--domain <host>', 'Override domain')
@@ -51,6 +53,14 @@ export const deployCommand = new Command('deploy')
51
53
  process.exit(1);
52
54
  }
53
55
 
56
+ // ModifyKey guard — check once before any submissions
57
+ const modifyKeyResult = await checkModifyKey(options);
58
+ if (modifyKeyResult.cancel) {
59
+ log.info('Submission cancelled');
60
+ return;
61
+ }
62
+ let activeModifyKey = modifyKeyResult.modifyKey;
63
+
54
64
  for (const [entryName, entry] of entries) {
55
65
  if (!entry) {
56
66
  log.warn(`Skipping unknown deployment: ${entryName}`);
@@ -71,8 +81,21 @@ export const deployCommand = new Command('deploy')
71
81
 
72
82
  const extraParams = { '_confirm': options.confirm };
73
83
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
74
- const body = await buildInputBody([`RowUID:${uid};column:${entity}.${column}@${file}`], extraParams);
75
- const result = await client.postUrlEncoded('/api/input/submit', body);
84
+ if (activeModifyKey) extraParams['_modify_key'] = activeModifyKey;
85
+
86
+ const dataExprs = [`RowUID:${uid};column:${entity}.${column}@${file}`];
87
+ let body = await buildInputBody(dataExprs, extraParams);
88
+ let result = await client.postUrlEncoded('/api/input/submit', body);
89
+
90
+ // Reactive ModifyKey retry
91
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
92
+ const retryMK = await handleModifyKeyError();
93
+ if (retryMK.cancel) { log.info('Submission cancelled'); break; }
94
+ activeModifyKey = retryMK.modifyKey;
95
+ extraParams['_modify_key'] = activeModifyKey;
96
+ body = await buildInputBody(dataExprs, extraParams);
97
+ result = await client.postUrlEncoded('/api/input/submit', body);
98
+ }
76
99
 
77
100
  if (result.successful) {
78
101
  log.success(`${entryName} deployed`);
@@ -4,6 +4,7 @@ import { buildInputBody, parseFileArg, checkSubmitErrors } from '../lib/input-pa
4
4
  import { formatResponse, formatError } from '../lib/formatter.js';
5
5
  import { loadAppConfig } from '../lib/config.js';
6
6
  import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
7
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
7
8
  import { log } from '../lib/logger.js';
8
9
 
9
10
  function collect(value, previous) {
@@ -16,6 +17,7 @@ export const inputCommand = new Command('input')
16
17
  .option('-f, --file <field=@path>', 'File attachment for multipart upload (repeatable)', collect, [])
17
18
  .option('-C, --confirm <value>', 'Commit changes: true (default) or false for validation only', 'true')
18
19
  .option('--ticket <id>', 'Override ticket ID (_OverrideTicketID)')
20
+ .option('--modifyKey <key>', 'Provide ModifyKey directly (skips interactive prompt)')
19
21
  .option('--login', 'Auto-login user created by this submission')
20
22
  .option('--transactional', 'Use transactional processing')
21
23
  .option('--json', 'Output raw JSON response')
@@ -45,6 +47,14 @@ export const inputCommand = new Command('input')
45
47
  }
46
48
  }
47
49
 
50
+ // ModifyKey guard
51
+ const modifyKeyResult = await checkModifyKey(options);
52
+ if (modifyKeyResult.cancel) {
53
+ log.info('Submission cancelled');
54
+ return;
55
+ }
56
+ if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
57
+
48
58
  // Check if data expressions include AppID; if not and config has one, prompt
49
59
  const allDataText = options.data.join(' ');
50
60
  const hasAppId = /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
@@ -93,6 +103,14 @@ export const inputCommand = new Command('input')
93
103
  const files = options.file.map(parseFileArg);
94
104
  let result = await client.postMultipart('/api/input/submit', fields, files);
95
105
 
106
+ // Reactive ModifyKey retry
107
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
108
+ const retryMK = await handleModifyKeyError();
109
+ if (retryMK.cancel) { log.info('Submission cancelled'); return; }
110
+ fields['_modify_key'] = retryMK.modifyKey;
111
+ result = await client.postMultipart('/api/input/submit', fields, files);
112
+ }
113
+
96
114
  // Retry with prompted params if needed (ticket, user, repo mismatch)
97
115
  const retryResult = await checkSubmitErrors(result);
98
116
  if (retryResult) {
@@ -112,6 +130,15 @@ export const inputCommand = new Command('input')
112
130
  let body = await buildInputBody(options.data, extraParams);
113
131
  let result = await client.postUrlEncoded('/api/input/submit', body);
114
132
 
133
+ // Reactive ModifyKey retry
134
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
135
+ const retryMK = await handleModifyKeyError();
136
+ if (retryMK.cancel) { log.info('Submission cancelled'); return; }
137
+ extraParams['_modify_key'] = retryMK.modifyKey;
138
+ body = await buildInputBody(options.data, extraParams);
139
+ result = await client.postUrlEncoded('/api/input/submit', body);
140
+ }
141
+
115
142
  // Retry with prompted params if needed (ticket, user, repo mismatch)
116
143
  const retryResult = await checkSubmitErrors(result);
117
144
  if (retryResult) {
@@ -8,6 +8,7 @@ import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
9
  import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
10
10
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
11
12
  import { setFileTimestamps } from '../lib/timestamps.js';
12
13
  import { findMetadataFiles } from '../lib/diff.js';
13
14
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
@@ -18,6 +19,7 @@ export const pushCommand = new Command('push')
18
19
  .argument('<path>', 'File or directory to push')
19
20
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
20
21
  .option('--ticket <id>', 'Override ticket ID')
22
+ .option('--modifyKey <key>', 'Provide ModifyKey directly (skips interactive prompt)')
21
23
  .option('--meta-only', 'Only push metadata changes, skip file content')
22
24
  .option('--content-only', 'Only push file content, skip metadata columns')
23
25
  .option('-y, --yes', 'Auto-accept all prompts (path refactoring, etc.)')
@@ -29,15 +31,23 @@ export const pushCommand = new Command('push')
29
31
  try {
30
32
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
31
33
 
34
+ // ModifyKey guard — check once before any submissions
35
+ const modifyKeyResult = await checkModifyKey(options);
36
+ if (modifyKeyResult.cancel) {
37
+ log.info('Submission cancelled');
38
+ return;
39
+ }
40
+ const modifyKey = modifyKeyResult.modifyKey;
41
+
32
42
  // Process pending deletions from synchronize.json
33
- await processPendingDeletes(client, options);
43
+ await processPendingDeletes(client, options, modifyKey);
34
44
 
35
45
  const pathStat = await stat(targetPath);
36
46
 
37
47
  if (pathStat.isDirectory()) {
38
- await pushDirectory(targetPath, client, options);
48
+ await pushDirectory(targetPath, client, options, modifyKey);
39
49
  } else {
40
- await pushSingleFile(targetPath, client, options);
50
+ await pushSingleFile(targetPath, client, options, modifyKey);
41
51
  }
42
52
  } catch (err) {
43
53
  formatError(err);
@@ -48,7 +58,7 @@ export const pushCommand = new Command('push')
48
58
  /**
49
59
  * Process pending delete entries from .dbo/synchronize.json
50
60
  */
51
- async function processPendingDeletes(client, options) {
61
+ async function processPendingDeletes(client, options, modifyKey = null) {
52
62
  const sync = await loadSynchronize();
53
63
  if (!sync.delete || sync.delete.length === 0) return;
54
64
 
@@ -62,6 +72,7 @@ async function processPendingDeletes(client, options) {
62
72
 
63
73
  const extraParams = { '_confirm': options.confirm || 'true' };
64
74
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
75
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
65
76
 
66
77
  const body = await buildInputBody([entry.expression], extraParams);
67
78
 
@@ -69,23 +80,33 @@ async function processPendingDeletes(client, options) {
69
80
  const result = await client.postUrlEncoded('/api/input/submit', body);
70
81
 
71
82
  // Retry with prompted params if needed
72
- const retryResult = await checkSubmitErrors(result);
73
- if (retryResult) {
74
- if (retryResult.skipRecord || retryResult.skipAll) {
83
+ const errorResult = await checkSubmitErrors(result);
84
+ if (errorResult) {
85
+ if (errorResult.skipRecord) {
75
86
  log.warn(` Skipping deletion of "${entry.name}"`);
76
87
  remaining.push(entry);
77
88
  continue;
78
89
  }
79
- const params = retryResult.retryParams || retryResult;
90
+ if (errorResult.skipAll) {
91
+ log.warn(` Skipping deletion of "${entry.name}" and all remaining`);
92
+ remaining.push(entry);
93
+ // Push all remaining entries too
94
+ const currentIdx = sync.delete.indexOf(entry);
95
+ for (let i = currentIdx + 1; i < sync.delete.length; i++) {
96
+ remaining.push(sync.delete[i]);
97
+ }
98
+ break;
99
+ }
100
+ const params = errorResult.retryParams || errorResult;
80
101
  Object.assign(extraParams, params);
81
102
  const retryBody = await buildInputBody([entry.expression], extraParams);
82
- const retryResult = await client.postUrlEncoded('/api/input/submit', retryBody);
83
- if (retryResult.successful) {
103
+ const retryResponse = await client.postUrlEncoded('/api/input/submit', retryBody);
104
+ if (retryResponse.successful) {
84
105
  log.success(` Deleted "${entry.name}" from server`);
85
106
  deletedUids.push(entry.UID);
86
107
  } else {
87
108
  log.error(` Failed to delete "${entry.name}"`);
88
- formatResponse(retryResult, { json: options.json, jq: options.jq });
109
+ formatResponse(retryResponse, { json: options.json, jq: options.jq });
89
110
  remaining.push(entry);
90
111
  }
91
112
  } else if (result.successful) {
@@ -119,7 +140,7 @@ async function processPendingDeletes(client, options) {
119
140
  /**
120
141
  * Push a single file using its companion .metadata.json
121
142
  */
122
- async function pushSingleFile(filePath, client, options) {
143
+ async function pushSingleFile(filePath, client, options, modifyKey = null) {
123
144
  // Find the metadata file
124
145
  const dir = dirname(filePath);
125
146
  const base = basename(filePath, extname(filePath));
@@ -133,13 +154,13 @@ async function pushSingleFile(filePath, client, options) {
133
154
  process.exit(1);
134
155
  }
135
156
 
136
- await pushFromMetadata(meta, metaPath, client, options);
157
+ await pushFromMetadata(meta, metaPath, client, options, null, modifyKey);
137
158
  }
138
159
 
139
160
  /**
140
161
  * Push all records found in a directory (recursive)
141
162
  */
142
- async function pushDirectory(dirPath, client, options) {
163
+ async function pushDirectory(dirPath, client, options, modifyKey = null) {
143
164
  const metaFiles = await findMetadataFiles(dirPath);
144
165
 
145
166
  if (metaFiles.length === 0) {
@@ -151,7 +172,6 @@ async function pushDirectory(dirPath, client, options) {
151
172
 
152
173
  // Load baseline for delta detection
153
174
  const baseline = await loadAppJsonBaseline();
154
- const config = await loadConfig();
155
175
 
156
176
  if (!baseline) {
157
177
  log.warn('No .app.json baseline found — performing full push (run "dbo clone" to enable delta sync)');
@@ -205,7 +225,7 @@ async function pushDirectory(dirPath, client, options) {
205
225
  let changedColumns = null;
206
226
  if (baseline) {
207
227
  try {
208
- changedColumns = await detectChangedColumns(metaPath, baseline, config);
228
+ changedColumns = await detectChangedColumns(metaPath, baseline);
209
229
  if (changedColumns.length === 0) {
210
230
  log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
211
231
  skipped++;
@@ -252,7 +272,7 @@ async function pushDirectory(dirPath, client, options) {
252
272
 
253
273
  for (const item of toPush) {
254
274
  try {
255
- const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns);
275
+ const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey);
256
276
  if (success) {
257
277
  succeeded++;
258
278
  successfulPushes.push(item);
@@ -286,7 +306,7 @@ async function pushDirectory(dirPath, client, options) {
286
306
  * @param {string[]|null} changedColumns - Optional array of changed column names (for delta sync)
287
307
  * @returns {Promise<boolean>} - True if push succeeded
288
308
  */
289
- async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null) {
309
+ async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null, modifyKey = null) {
290
310
  const uid = meta.UID || meta._id;
291
311
  const entity = meta._entity;
292
312
  const contentCols = new Set(meta._contentColumns || []);
@@ -350,10 +370,20 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
350
370
 
351
371
  const extraParams = { '_confirm': options.confirm };
352
372
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
373
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
353
374
 
354
375
  let body = await buildInputBody(dataExprs, extraParams);
355
376
  let result = await client.postUrlEncoded('/api/input/submit', body);
356
377
 
378
+ // Reactive ModifyKey retry — server rejected because key wasn't set locally
379
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
380
+ const retryMK = await handleModifyKeyError();
381
+ if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
382
+ extraParams['_modify_key'] = retryMK.modifyKey;
383
+ body = await buildInputBody(dataExprs, extraParams);
384
+ result = await client.postUrlEncoded('/api/input/submit', body);
385
+ }
386
+
357
387
  // Retry with prompted params if needed (ticket, user, repo mismatch)
358
388
  const retryResult = await checkSubmitErrors(result);
359
389
  if (retryResult) {
package/src/lib/config.js CHANGED
@@ -181,9 +181,10 @@ export async function loadAppConfig() {
181
181
  AppUID: config.AppUID || null,
182
182
  AppName: config.AppName || null,
183
183
  AppShortName: config.AppShortName || null,
184
+ AppModifyKey: config.AppModifyKey || null,
184
185
  };
185
186
  } catch {
186
- return { AppID: null, AppUID: null, AppName: null, AppShortName: null };
187
+ return { AppID: null, AppUID: null, AppName: null, AppShortName: null, AppModifyKey: null };
187
188
  }
188
189
  }
189
190
 
@@ -286,6 +287,37 @@ export async function loadEntityContentExtractions(entityKey) {
286
287
  }
287
288
  }
288
289
 
290
+ /**
291
+ * Save collision resolutions to .dbo/config.json.
292
+ * Maps file paths to the UID of the record the user chose to keep.
293
+ *
294
+ * @param {Object} resolutions - { "Bins/app/file.css": { keepUID: "abc", keepEntity: "content" } }
295
+ */
296
+ export async function saveCollisionResolutions(resolutions) {
297
+ await mkdir(dboDir(), { recursive: true });
298
+ let existing = {};
299
+ try {
300
+ existing = JSON.parse(await readFile(configPath(), 'utf8'));
301
+ } catch { /* no existing config */ }
302
+ existing.CollisionResolutions = resolutions;
303
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
304
+ }
305
+
306
+ /**
307
+ * Load collision resolutions from .dbo/config.json.
308
+ *
309
+ * @returns {Object} - { "Bins/app/file.css": { keepUID: "abc", keepEntity: "content" } }
310
+ */
311
+ export async function loadCollisionResolutions() {
312
+ try {
313
+ const raw = await readFile(configPath(), 'utf8');
314
+ const config = JSON.parse(raw);
315
+ return config.CollisionResolutions || {};
316
+ } catch {
317
+ return {};
318
+ }
319
+ }
320
+
289
321
  /**
290
322
  * Save user profile fields (FirstName, LastName, Email) into credentials.json.
291
323
  */
@@ -497,6 +529,32 @@ export async function getAllPluginScopes() {
497
529
  return result;
498
530
  }
499
531
 
532
+ // ─── AppModifyKey ─────────────────────────────────────────────────────────
533
+
534
+ /**
535
+ * Save AppModifyKey to .dbo/config.json.
536
+ * Pass null to remove the key (e.g. when server no longer has one).
537
+ */
538
+ export async function saveAppModifyKey(modifyKey) {
539
+ await mkdir(dboDir(), { recursive: true });
540
+ let existing = {};
541
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
542
+ if (modifyKey != null) existing.AppModifyKey = modifyKey;
543
+ else delete existing.AppModifyKey;
544
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
545
+ }
546
+
547
+ /**
548
+ * Load AppModifyKey from .dbo/config.json.
549
+ * Returns the key string or null if not set.
550
+ */
551
+ export async function loadAppModifyKey() {
552
+ try {
553
+ const raw = await readFile(configPath(), 'utf8');
554
+ return JSON.parse(raw).AppModifyKey || null;
555
+ } catch { return null; }
556
+ }
557
+
500
558
  // ─── Gitignore ────────────────────────────────────────────────────────────
501
559
 
502
560
  /**
package/src/lib/delta.js CHANGED
@@ -84,10 +84,9 @@ export async function compareFileContent(filePath, baselineValue) {
84
84
  *
85
85
  * @param {string} metaPath - Path to metadata.json file
86
86
  * @param {Object} baseline - The baseline JSON
87
- * @param {Object} config - CLI config (for resolving file paths)
88
87
  * @returns {Promise<string[]>} - Array of changed column names
89
88
  */
90
- export async function detectChangedColumns(metaPath, baseline, config) {
89
+ export async function detectChangedColumns(metaPath, baseline) {
91
90
  // Load current metadata
92
91
  const metaRaw = await readFile(metaPath, 'utf8');
93
92
  const metadata = JSON.parse(metaRaw);
@@ -160,27 +159,24 @@ function shouldSkipColumn(columnName) {
160
159
  }
161
160
 
162
161
  /**
163
- * Check if a value is a @reference object.
162
+ * Check if a value is a @reference (string starting with @).
164
163
  *
165
164
  * @param {*} value - Value to check
166
165
  * @returns {boolean} - True if reference
167
166
  */
168
167
  function isReference(value) {
169
- return value &&
170
- typeof value === 'object' &&
171
- !Array.isArray(value) &&
172
- value['@reference'] !== undefined;
168
+ return typeof value === 'string' && value.startsWith('@');
173
169
  }
174
170
 
175
171
  /**
176
172
  * Resolve a @reference path to absolute file path.
177
173
  *
178
- * @param {Object} reference - Reference object with @reference property
174
+ * @param {string} reference - Reference string starting with @ (e.g., "@file.html")
179
175
  * @param {string} baseDir - Base directory containing metadata
180
176
  * @returns {string} - Absolute file path
181
177
  */
182
178
  function resolveReferencePath(reference, baseDir) {
183
- const refPath = reference['@reference'];
179
+ const refPath = reference.substring(1); // Strip leading @
184
180
  return join(baseDir, refPath);
185
181
  }
186
182
 
@@ -116,7 +116,8 @@ export async function checkSubmitErrors(result) {
116
116
  const allText = messages.filter(m => typeof m === 'string').join(' ');
117
117
 
118
118
  // --- Ticket error detection (new interactive handling) ---
119
- const hasTicketError = allText.includes('ticket_error');
119
+ // Match ticket_error, ticket_lookup_error, and similar variants
120
+ const hasTicketError = allText.includes('ticket_error') || allText.includes('ticket_lookup_error');
120
121
  const hasRepoMismatch = allText.includes('repo_mismatch');
121
122
 
122
123
  if (hasTicketError) {
@@ -0,0 +1,96 @@
1
+ import { loadAppModifyKey, saveAppModifyKey } from './config.js';
2
+ import { log } from './logger.js';
3
+
4
+ /**
5
+ * Pre-submission ModifyKey guard.
6
+ * - If options.modifyKey flag is set, it takes precedence — no prompt, no config check.
7
+ * - Returns { modifyKey: null } if no key is set in config (no-op).
8
+ * - Prompts user to type the key; returns { modifyKey: string } on match.
9
+ * - Returns { modifyKey: null, cancel: true } on mismatch or cancel.
10
+ */
11
+ export async function checkModifyKey(options = {}) {
12
+ // --modifyKey flag takes precedence (mirrors --ticket / --confirm behavior)
13
+ if (options.modifyKey) return { modifyKey: options.modifyKey, cancel: false };
14
+
15
+ const storedKey = await loadAppModifyKey();
16
+ if (!storedKey) return { modifyKey: null, cancel: false };
17
+
18
+ log.warn('');
19
+ log.warn(' ⚠ This app is locked (production mode). A ModifyKey is required to submit changes.');
20
+ log.warn('');
21
+
22
+ const inquirer = (await import('inquirer')).default;
23
+ const { action } = await inquirer.prompt([{
24
+ type: 'list',
25
+ name: 'action',
26
+ message: 'This app has a ModifyKey set. How would you like to proceed?',
27
+ choices: [
28
+ { name: 'Enter the ModifyKey to proceed', value: 'enter' },
29
+ { name: 'Cancel submission', value: 'cancel' },
30
+ ],
31
+ }]);
32
+
33
+ if (action === 'cancel') return { modifyKey: null, cancel: true };
34
+
35
+ const { enteredKey } = await inquirer.prompt([{
36
+ type: 'input',
37
+ name: 'enteredKey',
38
+ message: 'ModifyKey:',
39
+ }]);
40
+
41
+ const key = enteredKey.trim();
42
+ if (!key) {
43
+ log.error(' No ModifyKey entered. Submission cancelled.');
44
+ return { modifyKey: null, cancel: true };
45
+ }
46
+
47
+ return { modifyKey: key, cancel: false };
48
+ }
49
+
50
+ /**
51
+ * Detects whether a server response error message requires a ModifyKey.
52
+ * Server error format: "Error:The '<app>' app (UID=...) has a ModifyKey – ..."
53
+ */
54
+ export function isModifyKeyError(responseMessage) {
55
+ return typeof responseMessage === 'string' && responseMessage.includes('has a ModifyKey');
56
+ }
57
+
58
+ /**
59
+ * Reactive ModifyKey handler — called after a submission fails with a ModifyKey error.
60
+ * Prompts the user to enter the key, saves it to config, and returns it for retry.
61
+ * Returns { modifyKey: string } on success or { modifyKey: null, cancel: true } on cancel.
62
+ */
63
+ export async function handleModifyKeyError() {
64
+ log.warn('');
65
+ log.warn(' ⚠ This app requires a ModifyKey. The key has not been set locally (try re-running `dbo clone`).');
66
+ log.warn('');
67
+
68
+ const inquirer = (await import('inquirer')).default;
69
+ const { action } = await inquirer.prompt([{
70
+ type: 'list',
71
+ name: 'action',
72
+ message: 'A ModifyKey is required. How would you like to proceed?',
73
+ choices: [
74
+ { name: 'Enter the ModifyKey to retry', value: 'enter' },
75
+ { name: 'Cancel submission', value: 'cancel' },
76
+ ],
77
+ }]);
78
+
79
+ if (action === 'cancel') return { modifyKey: null, cancel: true };
80
+
81
+ const { enteredKey } = await inquirer.prompt([{
82
+ type: 'input',
83
+ name: 'enteredKey',
84
+ message: 'ModifyKey:',
85
+ }]);
86
+
87
+ const key = enteredKey.trim();
88
+ if (!key) {
89
+ log.error(' No ModifyKey entered. Submission cancelled.');
90
+ return { modifyKey: null, cancel: true };
91
+ }
92
+
93
+ await saveAppModifyKey(key);
94
+ log.info(' ModifyKey saved to .dbo/config.json for future use.');
95
+ return { modifyKey: key, cancel: false };
96
+ }