@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 +28 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/src/commands/add.js +21 -0
- package/src/commands/clone.js +453 -22
- package/src/commands/content.js +25 -2
- package/src/commands/deploy.js +25 -2
- package/src/commands/input.js +27 -0
- package/src/commands/push.js +48 -18
- package/src/lib/config.js +59 -1
- package/src/lib/delta.js +5 -9
- package/src/lib/input-parser.js +2 -1
- package/src/lib/modify-key.js +96 -0
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
package/src/commands/add.js
CHANGED
|
@@ -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) {
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
if (
|
|
961
|
-
|
|
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
|
-
|
|
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'
|
|
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 {
|
package/src/commands/content.js
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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) {
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
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`);
|
package/src/commands/input.js
CHANGED
|
@@ -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) {
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
73
|
-
if (
|
|
74
|
-
if (
|
|
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
|
-
|
|
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
|
|
83
|
-
if (
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
179
|
+
const refPath = reference.substring(1); // Strip leading @
|
|
184
180
|
return join(baseDir, refPath);
|
|
185
181
|
}
|
|
186
182
|
|
package/src/lib/input-parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|