@dboio/cli 0.8.2 → 0.9.3
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 +134 -56
- package/package.json +1 -1
- package/src/commands/add.js +114 -10
- package/src/commands/clone.js +245 -85
- package/src/commands/deploy.js +1 -1
- package/src/commands/init.js +4 -3
- package/src/commands/input.js +2 -2
- package/src/commands/pull.js +1 -0
- package/src/commands/push.js +127 -27
- package/src/commands/rm.js +48 -16
- package/src/lib/filenames.js +151 -0
- package/src/lib/formatter.js +93 -11
- package/src/lib/ignore.js +8 -2
- package/src/lib/input-parser.js +30 -4
- package/src/lib/metadata-templates.js +264 -0
- package/src/lib/structure.js +30 -26
- package/src/lib/ticketing.js +73 -5
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
449
|
-
const mediaFilePath = resolveAtReference(
|
|
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:
|
|
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
|
|
503
|
-
const
|
|
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:
|
|
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
|
|
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
|
-
* - `
|
|
578
|
-
* - `
|
|
579
|
-
* purely organizational. Files in
|
|
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
|
-
* - `
|
|
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
|
|
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
|
-
* "
|
|
592
|
-
* "
|
|
593
|
-
* "
|
|
594
|
-
* "
|
|
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
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const withoutBins = cleaned.substring(
|
|
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
|
|
@@ -642,8 +738,12 @@ async function checkPathMismatch(meta, metaPath, options) {
|
|
|
642
738
|
|
|
643
739
|
if (!contentFileName) return;
|
|
644
740
|
|
|
645
|
-
// Compute the current path based on where the file actually is
|
|
646
|
-
|
|
741
|
+
// Compute the current path based on where the file actually is.
|
|
742
|
+
// Strip the ~UID from the filename — the metadata Path is the canonical
|
|
743
|
+
// server path and never contains the local ~UID suffix.
|
|
744
|
+
const uid = meta.UID;
|
|
745
|
+
const serverFileName = uid ? stripUidFromFilename(contentFileName, uid) : contentFileName;
|
|
746
|
+
const currentFilePath = join(metaDir, serverFileName);
|
|
647
747
|
const currentRelPath = relative(process.cwd(), currentFilePath);
|
|
648
748
|
|
|
649
749
|
// Normalize both paths for comparison
|
package/src/commands/rm.js
CHANGED
|
@@ -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
|
-
//
|
|
125
|
+
// Handle local files
|
|
125
126
|
if (!options.keepLocal) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
log.dim(` Deleted ${f}`);
|
|
130
|
-
}
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
}
|
package/src/lib/formatter.js
CHANGED
|
@@ -16,19 +16,30 @@ export function formatResponse(result, options = {}) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
if (result.successful) {
|
|
19
|
-
log.success(
|
|
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(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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);
|