@dboio/cli 0.8.2 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, stat, writeFile } from 'fs/promises';
2
+ import { readFile, stat, writeFile, rename as fsRename, mkdir } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
5
+ import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
@@ -11,9 +11,11 @@ import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, cl
11
11
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
12
12
  import { resolveTransactionKey } from '../lib/transaction-key.js';
13
13
  import { setFileTimestamps } from '../lib/timestamps.js';
14
+ import { stripUidFromFilename, renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
14
15
  import { findMetadataFiles } from '../lib/diff.js';
15
16
  import { loadIgnore } from '../lib/ignore.js';
16
17
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
18
+ import { BINS_DIR } from '../lib/structure.js';
17
19
 
18
20
  /**
19
21
  * Resolve an @reference file path to an absolute filesystem path.
@@ -91,6 +93,8 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
91
93
  const extraParams = { '_confirm': options.confirm || 'true' };
92
94
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
93
95
  if (modifyKey) extraParams['_modify_key'] = modifyKey;
96
+ const cachedUser2 = getSessionUserOverride();
97
+ if (cachedUser2) extraParams['_OverrideUserID'] = cachedUser2;
94
98
 
95
99
  const body = await buildInputBody([entry.expression], extraParams);
96
100
 
@@ -124,7 +128,7 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
124
128
  deletedUids.push(entry.UID);
125
129
  } else {
126
130
  log.error(` Failed to delete "${entry.name}"`);
127
- formatResponse(retryResponse, { json: options.json, jq: options.jq });
131
+ formatResponse(retryResponse, { json: options.json, jq: options.jq, verbose: options.verbose });
128
132
  remaining.push(entry);
129
133
  }
130
134
  } else if (result.successful) {
@@ -132,7 +136,7 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
132
136
  deletedUids.push(entry.UID);
133
137
  } else {
134
138
  log.error(` Failed to delete "${entry.name}"`);
135
- formatResponse(result, { json: options.json, jq: options.jq });
139
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
136
140
  remaining.push(entry);
137
141
  }
138
142
  } catch (err) {
@@ -141,6 +145,12 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
141
145
  }
142
146
  }
143
147
 
148
+ // Move __WILL_DELETE__ files to Trash/ for successfully deleted records
149
+ for (const entry of sync.delete) {
150
+ if (!deletedUids.includes(entry.UID)) continue;
151
+ await moveWillDeleteToTrash(entry);
152
+ }
153
+
144
154
  // Remove edit entries for successfully deleted records (spec requirement)
145
155
  if (deletedUids.length > 0) {
146
156
  sync.edit = (sync.edit || []).filter(e => !deletedUids.includes(e.UID));
@@ -155,6 +165,65 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
155
165
  }
156
166
  }
157
167
 
168
+ /**
169
+ * Move __WILL_DELETE__-prefixed files associated with a sync entry to Trash/.
170
+ */
171
+ async function moveWillDeleteToTrash(entry) {
172
+ if (!entry.metaPath) return;
173
+
174
+ const trashDir = join(process.cwd(), 'trash');
175
+ await mkdir(trashDir, { recursive: true });
176
+
177
+ const metaDir = dirname(entry.metaPath);
178
+ const metaBase = basename(entry.metaPath);
179
+ const willDeleteMeta = join(metaDir, `__WILL_DELETE__${metaBase}`);
180
+
181
+ const filesToMove = [];
182
+
183
+ try {
184
+ await stat(willDeleteMeta);
185
+ filesToMove.push({ from: willDeleteMeta, to: join(trashDir, metaBase) });
186
+
187
+ // Read the __WILL_DELETE__ metadata to find associated content files
188
+ const rawMeta = await readFile(willDeleteMeta, 'utf8');
189
+ const deletedMeta = JSON.parse(rawMeta);
190
+ for (const col of (deletedMeta._contentColumns || [])) {
191
+ const ref = deletedMeta[col];
192
+ if (ref && String(ref).startsWith('@')) {
193
+ const refFile = String(ref).substring(1);
194
+ const willDeleteContent = join(metaDir, `__WILL_DELETE__${refFile}`);
195
+ try {
196
+ await stat(willDeleteContent);
197
+ filesToMove.push({ from: willDeleteContent, to: join(trashDir, refFile) });
198
+ } catch {}
199
+ }
200
+ }
201
+ if (deletedMeta._mediaFile && String(deletedMeta._mediaFile).startsWith('@')) {
202
+ const refFile = String(deletedMeta._mediaFile).substring(1);
203
+ const willDeleteMedia = join(metaDir, `__WILL_DELETE__${refFile}`);
204
+ try {
205
+ await stat(willDeleteMedia);
206
+ filesToMove.push({ from: willDeleteMedia, to: join(trashDir, refFile) });
207
+ } catch {}
208
+ }
209
+ } catch {
210
+ // No __WILL_DELETE__ metadata file — nothing to move
211
+ return;
212
+ }
213
+
214
+ for (const { from, to } of filesToMove) {
215
+ // Handle Trash collision: append timestamp
216
+ let destPath = to;
217
+ try { await stat(destPath); destPath = `${to}.${Date.now()}`; } catch {}
218
+ try {
219
+ await fsRename(from, destPath);
220
+ log.dim(` Moved to trash: ${basename(destPath)}`);
221
+ } catch (err) {
222
+ log.warn(` Could not move to trash: ${from} — ${err.message}`);
223
+ }
224
+ }
225
+ }
226
+
158
227
  /**
159
228
  * Push a single file using its companion .metadata.json
160
229
  */
@@ -440,13 +509,18 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
440
509
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
441
510
  else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
442
511
  if (modifyKey) extraParams['_modify_key'] = modifyKey;
512
+ const cachedUser = getSessionUserOverride();
513
+ if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
443
514
 
444
515
  let result;
445
516
 
446
517
  if (isMediaUpload) {
447
518
  // Media file upload: use multipart/form-data
448
- const mediaFileName = String(meta._mediaFile).substring(1);
449
- const mediaFilePath = resolveAtReference(mediaFileName, metaDir);
519
+ const mediaLocalName = String(meta._mediaFile).substring(1);
520
+ const mediaFilePath = resolveAtReference(mediaLocalName, metaDir);
521
+ const mediaUid = meta.UID || meta._id;
522
+ const mediaUploadName = meta.Filename
523
+ || stripUidFromFilename(mediaLocalName, mediaUid);
450
524
 
451
525
  // Ensure at least one data expression to identify the row for the server
452
526
  if (dataExprs.length === 0 && meta.Filename) {
@@ -461,7 +535,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
461
535
  }
462
536
  }
463
537
 
464
- const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaFileName }];
538
+ const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaUploadName }];
465
539
  result = await client.postMultipart('/api/input/submit', fields, files);
466
540
  } else {
467
541
  let body = await buildInputBody(dataExprs, extraParams);
@@ -499,8 +573,11 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
499
573
  Object.assign(extraParams, params);
500
574
 
501
575
  if (isMediaUpload) {
502
- const mediaFileName = String(meta._mediaFile).substring(1);
503
- const mediaFilePath = resolveAtReference(mediaFileName, metaDir);
576
+ const retryLocalName = String(meta._mediaFile).substring(1);
577
+ const retryFilePath = resolveAtReference(retryLocalName, metaDir);
578
+ const retryUid = meta.UID || meta._id;
579
+ const retryUploadName = meta.Filename
580
+ || stripUidFromFilename(retryLocalName, retryUid);
504
581
  const fields = { ...extraParams };
505
582
  for (const expr of dataExprs) {
506
583
  const eqIdx = expr.indexOf('=');
@@ -508,7 +585,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
508
585
  fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
509
586
  }
510
587
  }
511
- const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaFileName }];
588
+ const files = [{ fieldName: 'file', filePath: retryFilePath, fileName: retryUploadName }];
512
589
  result = await client.postMultipart('/api/input/submit', fields, files);
513
590
  } else {
514
591
  const body = await buildInputBody(dataExprs, extraParams);
@@ -516,7 +593,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
516
593
  }
517
594
  }
518
595
 
519
- formatResponse(result, { json: options.json, jq: options.jq });
596
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
520
597
 
521
598
  // Update metadata on disk if path was changed
522
599
  if (metaUpdated) {
@@ -531,6 +608,25 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
531
608
  // Clean up per-record ticket on success
532
609
  await clearRecordTicket(uid || id);
533
610
 
611
+ // Post-UID rename: if the record lacked a UID and the server returned one
612
+ try {
613
+ const editResults2 = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
614
+ const addResults2 = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
615
+ const allResults = [...editResults2, ...addResults2];
616
+ if (allResults.length > 0 && !meta.UID) {
617
+ const serverUID = allResults[0].UID;
618
+ if (serverUID && !hasUidInFilename(basename(metaPath), serverUID)) {
619
+ const config2 = await loadConfig();
620
+ const renameResult = await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
621
+ if (renameResult.newMetaPath !== metaPath) {
622
+ log.success(` Renamed to ~${serverUID} convention`);
623
+ // Update metaPath reference for subsequent timestamp operations
624
+ // (metaPath is const, but timestamp update below re-reads from meta)
625
+ }
626
+ }
627
+ }
628
+ } catch { /* non-critical rename */ }
629
+
534
630
  // Update file timestamps from server response
535
631
  try {
536
632
  const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
@@ -569,29 +665,29 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
569
665
  /**
570
666
  * Normalize a local file path for comparison with server-side Path.
571
667
  *
572
- * The Bins/ directory is used for local file organization and does not reflect
668
+ * The bins/ directory is used for local file organization and does not reflect
573
669
  * the actual server-side serving path. This function strips organizational prefixes
574
670
  * to enable correct path comparison during push operations.
575
671
  *
576
672
  * **Directory Structure:**
577
- * - `Bins/` — Local organizational root (always stripped)
578
- * - `Bins/app/` — Special case: the "app" subdirectory is also stripped because it's
579
- * purely organizational. Files in Bins/app/ are served from the app root without
673
+ * - `bins/` — Local organizational root (always stripped)
674
+ * - `bins/app/` — Special case: the "app" subdirectory is also stripped because it's
675
+ * purely organizational. Files in bins/app/ are served from the app root without
580
676
  * the "app/" prefix on the server.
581
- * - `Bins/custom_name/` — Custom bin directories (tpl/, ticket_test/, etc.) are
677
+ * - `bins/custom_name/` — Custom bin directories (tpl/, ticket_test/, etc.) are
582
678
  * preserved because they represent actual bin hierarchies that serve from their
583
679
  * directory name.
584
680
  *
585
681
  * **Why "app/" is special:**
586
- * The main app bin is placed in Bins/app/ locally for organization, but server-side
682
+ * The main app bin is placed in bins/app/ locally for organization, but server-side
587
683
  * these files are served from the root path (no "app/" prefix). Other custom bins
588
684
  * like "tpl/" maintain their directory name in the serving path.
589
685
  *
590
686
  * Examples:
591
- * "Bins/app/assets/css/file.css" → "assets/css/file.css" (strips Bins/app/)
592
- * "Bins/tpl/header.html" → "tpl/header.html" (preserves tpl/)
593
- * "Bins/assets/css/file.css" → "assets/css/file.css" (strips Bins/ only)
594
- * "Sites/MySite/content/page.html" → "Sites/MySite/content/page.html" (unchanged)
687
+ * "bins/app/assets/css/file.css" → "assets/css/file.css" (strips bins/app/)
688
+ * "bins/tpl/header.html" → "tpl/header.html" (preserves tpl/)
689
+ * "bins/assets/css/file.css" → "assets/css/file.css" (strips bins/ only)
690
+ * "site/MySite/content/page.html" → "site/MySite/content/page.html" (unchanged)
595
691
  *
596
692
  * @param {string} localPath - Relative path from project root
597
693
  * @returns {string} - Normalized path for comparison with metadata Path column
@@ -603,10 +699,10 @@ function normalizePathForComparison(localPath) {
603
699
  // Strip leading and trailing slashes
604
700
  const cleaned = normalized.replace(/^\/+|\/+$/g, '');
605
701
 
606
- // Check if path starts with "Bins/" organizational directory
607
- if (cleaned.startsWith('Bins/')) {
608
- // Remove "Bins/" prefix (length = 5)
609
- const withoutBins = cleaned.substring(5);
702
+ // Check if path starts with bins/ organizational directory
703
+ const binsPrefix = BINS_DIR + '/';
704
+ if (cleaned.startsWith(binsPrefix)) {
705
+ const withoutBins = cleaned.substring(binsPrefix.length);
610
706
 
611
707
  // Special case: strip "app/" organizational subdirectory
612
708
  // This is the only special subdirectory - all others (tpl/, assets/, etc.) are preserved
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, unlink, stat, rm as fsRm } from 'fs/promises';
2
+ import { readFile, unlink, stat, rm as fsRm, rename } from 'fs/promises';
3
3
  import { join, dirname, basename, extname } from 'path';
4
4
  import { log } from '../lib/logger.js';
5
5
  import { formatError } from '../lib/formatter.js';
@@ -12,6 +12,7 @@ export const rmCommand = new Command('rm')
12
12
  .argument('<path>', 'File, metadata.json, or directory to remove')
13
13
  .option('-f, --force', 'Skip confirmation prompts')
14
14
  .option('--keep-local', 'Only stage server deletion, do not delete local files')
15
+ .option('--hard', 'Immediately delete local files (no Trash; legacy behavior)')
15
16
  .action(async (targetPath, options) => {
16
17
  try {
17
18
  const pathStat = await stat(targetPath).catch(() => null);
@@ -113,21 +114,35 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
113
114
  }
114
115
  }
115
116
 
116
- // Stage deletion
117
+ // Stage deletion (include metaPath for Trash workflow in push.js)
117
118
  const expression = `RowID:del${rowId};entity:${entity}=true`;
118
- await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
119
+ await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
119
120
  log.success(` Staged: ${displayName} → ${expression}`);
120
121
 
121
122
  // Remove from app.json
122
123
  await removeAppJsonReference(metaPath);
123
124
 
124
- // Delete local files
125
+ // Handle local files
125
126
  if (!options.keepLocal) {
126
- for (const f of localFiles) {
127
- try {
128
- await unlink(f);
129
- log.dim(` Deleted ${f}`);
130
- } catch { /* file may not exist */ }
127
+ if (options.hard) {
128
+ // --hard: original behavior — immediate delete
129
+ for (const f of localFiles) {
130
+ try { await unlink(f); log.dim(` Deleted ${f}`); } catch {}
131
+ }
132
+ } else {
133
+ // Default: rename to __WILL_DELETE__ prefix
134
+ for (const f of localFiles) {
135
+ const fDir = dirname(f);
136
+ const fName = basename(f);
137
+ if (fName.startsWith('__WILL_DELETE__')) continue; // idempotency guard
138
+ const willDeletePath = join(fDir, `__WILL_DELETE__${fName}`);
139
+ try {
140
+ await rename(f, willDeletePath);
141
+ log.dim(` Staged for delete: ${willDeletePath}`);
142
+ } catch (err) {
143
+ log.warn(` Could not rename ${f}: ${err.message}`);
144
+ }
145
+ }
131
146
  }
132
147
  }
133
148
 
@@ -196,18 +211,35 @@ async function rmFile(filePath, options) {
196
211
  }
197
212
 
198
213
  const expression = `RowID:del${rowId};entity:${entity}=true`;
199
- await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
214
+ await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
200
215
  log.success(`Staged deletion: ${expression}`);
201
216
 
202
217
  await removeAppJsonReference(metaPath);
203
218
 
204
219
  if (!options.keepLocal) {
205
- for (const f of localFiles) {
206
- try {
207
- await unlink(f);
208
- log.dim(` Deleted ${f}`);
209
- } catch {
210
- log.warn(` Could not delete ${f} (may not exist)`);
220
+ if (options.hard) {
221
+ // --hard: original behavior — immediate delete
222
+ for (const f of localFiles) {
223
+ try {
224
+ await unlink(f);
225
+ log.dim(` Deleted ${f}`);
226
+ } catch {
227
+ log.warn(` Could not delete ${f} (may not exist)`);
228
+ }
229
+ }
230
+ } else {
231
+ // Default: rename to __WILL_DELETE__ prefix
232
+ for (const f of localFiles) {
233
+ const fDir = dirname(f);
234
+ const fName = basename(f);
235
+ if (fName.startsWith('__WILL_DELETE__')) continue; // idempotency guard
236
+ const willDeletePath = join(fDir, `__WILL_DELETE__${fName}`);
237
+ try {
238
+ await rename(f, willDeletePath);
239
+ log.dim(` Staged for delete: ${willDeletePath}`);
240
+ } catch (err) {
241
+ log.warn(` Could not rename ${f}: ${err.message}`);
242
+ }
211
243
  }
212
244
  }
213
245
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Filename convention helpers for the UID tilde convention.
3
+ * All entity files use <basename>~<uid>.<ext> as the local filename.
4
+ */
5
+
6
+ import { rename, writeFile } from 'fs/promises';
7
+ import { join, dirname, basename, extname } from 'path';
8
+
9
+ /**
10
+ * Build a UID-bearing filename following the tilde convention.
11
+ * If name === uid, omit the tilde suffix (avoids <uid>~<uid>).
12
+ *
13
+ * @param {string} name - Sanitized base name (no extension)
14
+ * @param {string} uid - Server-assigned UID
15
+ * @param {string} [ext] - File extension WITHOUT leading dot (e.g. 'css', 'png', '')
16
+ * @returns {string} - e.g. "colors~abc123.css" or "abc123.css" or "colors~abc123"
17
+ */
18
+ export function buildUidFilename(name, uid, ext = '') {
19
+ const base = (name === uid) ? uid : `${name}~${uid}`;
20
+ return ext ? `${base}.${ext}` : base;
21
+ }
22
+
23
+ /**
24
+ * Strip the ~<uid> portion from a local filename.
25
+ * Used when the local file is "logo~def456.png" but the upload should send "logo.png".
26
+ *
27
+ * If the uid is not found in the filename, returns localName unchanged.
28
+ *
29
+ * @param {string} localName - e.g. "logo~def456.png"
30
+ * @param {string} uid - e.g. "def456"
31
+ * @returns {string} - e.g. "logo.png"
32
+ */
33
+ export function stripUidFromFilename(localName, uid) {
34
+ if (!uid || !localName) return localName;
35
+ const marker = `~${uid}`;
36
+ const idx = localName.indexOf(marker);
37
+ if (idx === -1) return localName;
38
+ return localName.slice(0, idx) + localName.slice(idx + marker.length);
39
+ }
40
+
41
+ /**
42
+ * Check whether a filename already contains ~<uid>.
43
+ */
44
+ export function hasUidInFilename(filename, uid) {
45
+ return typeof filename === 'string' && typeof uid === 'string'
46
+ && filename.includes(`~${uid}`);
47
+ }
48
+
49
+ /**
50
+ * Detect whether a metadata filename uses the OLD dot-separated convention:
51
+ * <name>.<uid>.metadata.json
52
+ * where uid is a sequence of ≥10 lowercase alphanumeric characters.
53
+ *
54
+ * Returns { name, uid } or null.
55
+ */
56
+ export function detectLegacyDotUid(filename) {
57
+ const match = filename.match(/^(.+)\.([a-z0-9]{10,})\.metadata\.json$/);
58
+ return match ? { name: match[1], uid: match[2] } : null;
59
+ }
60
+
61
+ /**
62
+ * Rename a file pair (content + metadata) to the ~uid convention after server assigns a UID.
63
+ * Updates @reference values inside the metadata file.
64
+ * Restores file timestamps from _LastUpdated.
65
+ *
66
+ * @param {Object} meta - Current metadata object
67
+ * @param {string} metaPath - Absolute path to the .metadata.json file
68
+ * @param {string} uid - Newly assigned UID from server
69
+ * @param {string} lastUpdated - Server _LastUpdated value
70
+ * @param {string} serverTz - Timezone for timestamp conversion
71
+ * @returns {Promise<{ newMetaPath: string, newFilePath: string|null }>}
72
+ */
73
+ export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
74
+ const metaDir = dirname(metaPath);
75
+ const metaBase = basename(metaPath, '.metadata.json'); // e.g. "colors" or "logo.png"
76
+
77
+ if (hasUidInFilename(metaBase, uid)) {
78
+ return { newMetaPath: metaPath, newFilePath: null }; // already renamed
79
+ }
80
+
81
+ // For media files: metaBase = "logo.png", newBase = "logo~uid.png"
82
+ // For content files: metaBase = "colors", newBase = "colors~uid"
83
+ const metaBaseExt = extname(metaBase); // ".png" for media metadata, "" for content metadata
84
+ let newMetaBase;
85
+ if (metaBaseExt) {
86
+ // Media: "logo.png" → "logo~uid.png"
87
+ const nameWithoutExt = metaBase.slice(0, -metaBaseExt.length);
88
+ newMetaBase = `${buildUidFilename(nameWithoutExt, uid)}${metaBaseExt}`;
89
+ } else {
90
+ // Content/entity-dir: "colors" → "colors~uid"
91
+ newMetaBase = buildUidFilename(metaBase, uid);
92
+ }
93
+
94
+ const newMetaPath = join(metaDir, `${newMetaBase}.metadata.json`);
95
+
96
+ // Update metadata in memory
97
+ meta.UID = uid;
98
+ const updatedMeta = { ...meta };
99
+
100
+ // Rename content files referenced in _contentColumns and _mediaFile
101
+ const contentCols = [...(meta._contentColumns || [])];
102
+ if (meta._mediaFile) contentCols.push('_mediaFile');
103
+
104
+ let newFilePath = null;
105
+
106
+ for (const col of contentCols) {
107
+ const ref = meta[col];
108
+ if (!ref || !String(ref).startsWith('@')) continue;
109
+
110
+ const oldRefFile = String(ref).substring(1);
111
+ const oldFilePath = join(metaDir, oldRefFile);
112
+
113
+ // Compute new filename: insert uid
114
+ const oldExt = extname(oldRefFile); // ".css", ".png"
115
+ const oldBase = basename(oldRefFile, oldExt); // "colors" or "logo"
116
+ const newRefBase = buildUidFilename(oldBase, uid);
117
+ const newRefFile = oldExt ? `${newRefBase}${oldExt}` : newRefBase;
118
+ const newRefPath = join(metaDir, newRefFile);
119
+
120
+ try {
121
+ await rename(oldFilePath, newRefPath);
122
+ updatedMeta[col] = `@${newRefFile}`;
123
+ if (!newFilePath) newFilePath = newRefPath;
124
+ } catch {
125
+ // File may already be renamed or may not exist
126
+ }
127
+ }
128
+
129
+ // Write updated metadata to new path
130
+ await writeFile(newMetaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
131
+
132
+ // Remove old metadata file if it's different from newMetaPath
133
+ if (metaPath !== newMetaPath) {
134
+ try { await rename(metaPath, newMetaPath); } catch { /* already written above */ }
135
+ }
136
+
137
+ // Restore timestamps
138
+ if (serverTz && lastUpdated) {
139
+ const { setFileTimestamps } = await import('./timestamps.js');
140
+ for (const col of contentCols) {
141
+ const ref = updatedMeta[col];
142
+ if (ref && String(ref).startsWith('@')) {
143
+ const fp = join(metaDir, String(ref).substring(1));
144
+ try { await setFileTimestamps(fp, lastUpdated, lastUpdated, serverTz); } catch {}
145
+ }
146
+ }
147
+ try { await setFileTimestamps(newMetaPath, lastUpdated, lastUpdated, serverTz); } catch {}
148
+ }
149
+
150
+ return { newMetaPath, newFilePath, updatedMeta };
151
+ }
@@ -16,19 +16,30 @@ export function formatResponse(result, options = {}) {
16
16
  }
17
17
 
18
18
  if (result.successful) {
19
- log.success(`Request successful ${chalk.dim(result.url || '')}`);
19
+ log.success('Request successful');
20
+ // Show the payload as compact highlighted JSON so the user sees what changed
21
+ if (result.payload) {
22
+ printJsonCompact(result.payload);
23
+ }
20
24
  } else {
21
- log.error(`Request failed ${chalk.dim(result.url || '')}`);
22
- }
23
-
24
- if (result.messages && result.messages.length > 0) {
25
- for (const msg of result.messages) {
26
- log.label('Message', msg);
25
+ log.error('Request failed');
26
+ if (result.messages && result.messages.length > 0) {
27
+ for (const msg of result.messages) {
28
+ log.label('Message', truncateParamValues(String(msg)));
29
+ }
30
+ }
31
+ // On failure: show compact response summary; full JSON only with --verbose
32
+ if (result.data) {
33
+ if (options.verbose) {
34
+ log.plain('');
35
+ log.dim('─── Response JSON ───');
36
+ printJson(result.data);
37
+ } else {
38
+ log.plain('');
39
+ log.dim('─── Response (use -v for full JSON) ───');
40
+ printJsonCompact(result.data);
41
+ }
27
42
  }
28
- }
29
-
30
- if (result.payload) {
31
- formatPayload(result.payload, options);
32
43
  }
33
44
  }
34
45
 
@@ -301,6 +312,77 @@ function printJson(data) {
301
312
  log.plain(colored);
302
313
  }
303
314
 
315
+ /**
316
+ * Print JSON with syntax highlighting, truncating long string values.
317
+ * Strings over 120 chars are collapsed to a short preview + byte count.
318
+ */
319
+ function printJsonCompact(data) {
320
+ const json = JSON.stringify(data, (key, value) => {
321
+ if (typeof value === 'string' && value.length > 120) {
322
+ const preview = value.substring(0, 60).replace(/\n/g, '\\n');
323
+ const kb = (value.length / 1024).toFixed(1);
324
+ return `${preview}… (${kb} KB)`;
325
+ }
326
+ return value;
327
+ }, 2);
328
+ if (!json) { log.plain('null'); return; }
329
+
330
+ const colored = json.replace(
331
+ /("(?:[^"\\]|\\.)*")\s*:/g,
332
+ (match, k) => chalk.cyan(k) + ':'
333
+ ).replace(
334
+ /:\s*("(?:[^"\\]|\\.)*")/g,
335
+ (match, val) => ': ' + chalk.green(val)
336
+ ).replace(
337
+ /:\s*(\d+\.?\d*)/g,
338
+ (match, num) => ': ' + chalk.yellow(num)
339
+ ).replace(
340
+ /:\s*(true|false|null)/g,
341
+ (match, val) => ': ' + chalk.magenta(val)
342
+ );
343
+
344
+ log.plain(colored);
345
+ }
346
+
347
+ /**
348
+ * Truncate long parameter values in server error messages.
349
+ * SQL debug output contains lines like " @Content = <entire HTML file>" —
350
+ * collapse those so the terminal isn't flooded.
351
+ *
352
+ * Strategy: split on lines, find " @Param = value" lines where the value
353
+ * portion (everything after " = ") spans many characters, and collapse.
354
+ */
355
+ function truncateParamValues(text) {
356
+ const lines = text.split('\n');
357
+ const result = [];
358
+ let i = 0;
359
+
360
+ while (i < lines.length) {
361
+ const paramMatch = lines[i].match(/^(\s*@\w+\s*=\s*)(.*)/);
362
+ if (paramMatch) {
363
+ const prefix = paramMatch[1]; // " @Content = "
364
+ // Collect continuation lines (lines that don't start a new @param or section marker)
365
+ let value = paramMatch[2];
366
+ while (i + 1 < lines.length && !lines[i + 1].match(/^\s*@\w+\s*=/) && !lines[i + 1].match(/^\[/)) {
367
+ i++;
368
+ value += '\n' + lines[i];
369
+ }
370
+ if (value.length > 120) {
371
+ const preview = value.substring(0, 60).replace(/\n/g, ' ').trim();
372
+ const kb = (value.length / 1024).toFixed(1);
373
+ result.push(`${prefix}${preview}... (${kb} KB)`);
374
+ } else {
375
+ result.push(`${prefix}${value}`);
376
+ }
377
+ } else {
378
+ result.push(lines[i]);
379
+ }
380
+ i++;
381
+ }
382
+
383
+ return result.join('\n');
384
+ }
385
+
304
386
  export function formatError(err) {
305
387
  if (err.message) {
306
388
  log.error(err.message);
package/src/lib/ignore.js CHANGED
@@ -19,7 +19,9 @@ app.json
19
19
  .app.json
20
20
  dbo.deploy.json
21
21
 
22
- # Editor / IDE
22
+ # Editor / IDE / OS
23
+ .DS_Store
24
+ Thumbs.db
23
25
  .idea/
24
26
  .vscode/
25
27
  *.codekit3
@@ -39,6 +41,10 @@ node_modules/
39
41
  package.json
40
42
  package-lock.json
41
43
 
44
+ # Local development (not pushed to DBO server)
45
+ src/
46
+ tests/
47
+
42
48
  # Documentation (repo scaffolding)
43
49
  SETUP.md
44
50
  README.md
@@ -58,7 +64,7 @@ export function getDefaultFileContent() {
58
64
  /**
59
65
  * Extract just the active pattern lines from DEFAULT_FILE_CONTENT.
60
66
  */
61
- function getDefaultPatternLines() {
67
+ export function getDefaultPatternLines() {
62
68
  return DEFAULT_FILE_CONTENT
63
69
  .split('\n')
64
70
  .filter(l => l && !l.startsWith('#'));