@dboio/cli 0.6.6 → 0.6.8
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 +93 -2
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/src/commands/add.js +33 -4
- package/src/commands/clone.js +443 -22
- package/src/commands/init.js +1 -1
- package/src/commands/input.js +32 -8
- package/src/commands/login.js +1 -1
- package/src/commands/push.js +55 -9
- package/src/lib/config.js +31 -0
- package/src/lib/delta.js +5 -9
- package/src/lib/input-parser.js +180 -9
- package/src/lib/ticketing.js +189 -0
package/src/commands/push.js
CHANGED
|
@@ -7,6 +7,7 @@ import { formatResponse, formatError } from '../lib/formatter.js';
|
|
|
7
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
|
+
import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
|
|
10
11
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
11
12
|
import { findMetadataFiles } from '../lib/diff.js';
|
|
12
13
|
import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
|
|
@@ -68,9 +69,15 @@ async function processPendingDeletes(client, options) {
|
|
|
68
69
|
const result = await client.postUrlEncoded('/api/input/submit', body);
|
|
69
70
|
|
|
70
71
|
// Retry with prompted params if needed
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
72
|
+
const retryResult = await checkSubmitErrors(result);
|
|
73
|
+
if (retryResult) {
|
|
74
|
+
if (retryResult.skipRecord || retryResult.skipAll) {
|
|
75
|
+
log.warn(` Skipping deletion of "${entry.name}"`);
|
|
76
|
+
remaining.push(entry);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const params = retryResult.retryParams || retryResult;
|
|
80
|
+
Object.assign(extraParams, params);
|
|
74
81
|
const retryBody = await buildInputBody([entry.expression], extraParams);
|
|
75
82
|
const retryResult = await client.postUrlEncoded('/api/input/submit', retryBody);
|
|
76
83
|
if (retryResult.successful) {
|
|
@@ -144,7 +151,6 @@ async function pushDirectory(dirPath, client, options) {
|
|
|
144
151
|
|
|
145
152
|
// Load baseline for delta detection
|
|
146
153
|
const baseline = await loadAppJsonBaseline();
|
|
147
|
-
const config = await loadConfig();
|
|
148
154
|
|
|
149
155
|
if (!baseline) {
|
|
150
156
|
log.warn('No .app.json baseline found — performing full push (run "dbo clone" to enable delta sync)');
|
|
@@ -198,7 +204,7 @@ async function pushDirectory(dirPath, client, options) {
|
|
|
198
204
|
let changedColumns = null;
|
|
199
205
|
if (baseline) {
|
|
200
206
|
try {
|
|
201
|
-
changedColumns = await detectChangedColumns(metaPath, baseline
|
|
207
|
+
changedColumns = await detectChangedColumns(metaPath, baseline);
|
|
202
208
|
if (changedColumns.length === 0) {
|
|
203
209
|
log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
|
|
204
210
|
skipped++;
|
|
@@ -217,6 +223,19 @@ async function pushDirectory(dirPath, client, options) {
|
|
|
217
223
|
return;
|
|
218
224
|
}
|
|
219
225
|
|
|
226
|
+
// Pre-flight ticket validation (only if no --ticket flag)
|
|
227
|
+
if (!options.ticket && toPush.length > 0) {
|
|
228
|
+
const ticketCheck = await checkStoredTicket(options);
|
|
229
|
+
if (ticketCheck.cancel) {
|
|
230
|
+
log.info('Submission cancelled');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (ticketCheck.clearTicket) {
|
|
234
|
+
await clearGlobalTicket();
|
|
235
|
+
log.dim(' Cleared stored ticket');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
220
239
|
// Group by entity and apply dependency ordering
|
|
221
240
|
const byEntity = {};
|
|
222
241
|
for (const item of toPush) {
|
|
@@ -240,6 +259,10 @@ async function pushDirectory(dirPath, client, options) {
|
|
|
240
259
|
failed++;
|
|
241
260
|
}
|
|
242
261
|
} catch (err) {
|
|
262
|
+
if (err.message === 'SKIP_ALL') {
|
|
263
|
+
log.info('Skipping remaining records');
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
243
266
|
log.error(`Failed: ${item.metaPath} — ${err.message}`);
|
|
244
267
|
failed++;
|
|
245
268
|
}
|
|
@@ -321,16 +344,36 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
321
344
|
const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
|
|
322
345
|
log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) — ${fieldLabel}`);
|
|
323
346
|
|
|
347
|
+
// Apply stored ticket if no --ticket flag
|
|
348
|
+
await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options);
|
|
349
|
+
|
|
324
350
|
const extraParams = { '_confirm': options.confirm };
|
|
325
351
|
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
326
352
|
|
|
327
353
|
let body = await buildInputBody(dataExprs, extraParams);
|
|
328
354
|
let result = await client.postUrlEncoded('/api/input/submit', body);
|
|
329
355
|
|
|
330
|
-
// Retry with prompted params if needed (ticket, user)
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
|
|
356
|
+
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
357
|
+
const retryResult = await checkSubmitErrors(result);
|
|
358
|
+
if (retryResult) {
|
|
359
|
+
// Handle skip actions
|
|
360
|
+
if (retryResult.skipRecord) {
|
|
361
|
+
log.warn(' Skipping record');
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
if (retryResult.skipAll) {
|
|
365
|
+
throw new Error('SKIP_ALL');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Append ticket expressions
|
|
369
|
+
if (retryResult.ticketExpressions?.length > 0) {
|
|
370
|
+
dataExprs.push(...retryResult.ticketExpressions);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Merge retry params (new-style has retryParams nested, legacy is flat)
|
|
374
|
+
const params = retryResult.retryParams || retryResult;
|
|
375
|
+
Object.assign(extraParams, params);
|
|
376
|
+
|
|
334
377
|
body = await buildInputBody(dataExprs, extraParams);
|
|
335
378
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
336
379
|
}
|
|
@@ -347,6 +390,9 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
347
390
|
return false;
|
|
348
391
|
}
|
|
349
392
|
|
|
393
|
+
// Clean up per-record ticket on success
|
|
394
|
+
await clearRecordTicket(uid);
|
|
395
|
+
|
|
350
396
|
// Update file timestamps from server response
|
|
351
397
|
try {
|
|
352
398
|
const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
|
package/src/lib/config.js
CHANGED
|
@@ -286,6 +286,37 @@ export async function loadEntityContentExtractions(entityKey) {
|
|
|
286
286
|
}
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Save collision resolutions to .dbo/config.json.
|
|
291
|
+
* Maps file paths to the UID of the record the user chose to keep.
|
|
292
|
+
*
|
|
293
|
+
* @param {Object} resolutions - { "Bins/app/file.css": { keepUID: "abc", keepEntity: "content" } }
|
|
294
|
+
*/
|
|
295
|
+
export async function saveCollisionResolutions(resolutions) {
|
|
296
|
+
await mkdir(dboDir(), { recursive: true });
|
|
297
|
+
let existing = {};
|
|
298
|
+
try {
|
|
299
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
300
|
+
} catch { /* no existing config */ }
|
|
301
|
+
existing.CollisionResolutions = resolutions;
|
|
302
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Load collision resolutions from .dbo/config.json.
|
|
307
|
+
*
|
|
308
|
+
* @returns {Object} - { "Bins/app/file.css": { keepUID: "abc", keepEntity: "content" } }
|
|
309
|
+
*/
|
|
310
|
+
export async function loadCollisionResolutions() {
|
|
311
|
+
try {
|
|
312
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
313
|
+
const config = JSON.parse(raw);
|
|
314
|
+
return config.CollisionResolutions || {};
|
|
315
|
+
} catch {
|
|
316
|
+
return {};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
289
320
|
/**
|
|
290
321
|
* Save user profile fields (FirstName, LastName, Email) into credentials.json.
|
|
291
322
|
*/
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'fs/promises';
|
|
2
2
|
import { log } from './logger.js';
|
|
3
3
|
import { loadUserInfo } from './config.js';
|
|
4
|
+
import { buildTicketExpression, setGlobalTicket, setRecordTicket } from './ticketing.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Parse DBO input syntax and build form data.
|
|
@@ -96,24 +97,41 @@ const USER_ID_PATTERNS = [
|
|
|
96
97
|
* resolved by prompting the user for missing parameters.
|
|
97
98
|
*
|
|
98
99
|
* Detects:
|
|
99
|
-
* -
|
|
100
|
+
* - ticket_error → interactive ticket recovery (4 options)
|
|
101
|
+
* - repo_mismatch → repository mismatch recovery (6 options)
|
|
102
|
+
* - ticket_lookup_required_error → prompts for Ticket ID (legacy)
|
|
100
103
|
* - LoggedInUser_UID, LoggedInUserID, CurrentUserID, UserID not found
|
|
101
104
|
* → prompts for User ID or UID (session not authenticated)
|
|
102
105
|
*
|
|
103
|
-
* Returns an object
|
|
104
|
-
*
|
|
106
|
+
* Returns an object with retry information, or null if no recoverable errors found.
|
|
107
|
+
*
|
|
108
|
+
* Return shape for ticket errors:
|
|
109
|
+
* { retryParams, ticketExpressions, skipRecord, skipAll }
|
|
110
|
+
*
|
|
111
|
+
* Return shape for legacy/user errors (backward compatible):
|
|
112
|
+
* { _OverrideTicketID, _OverrideUserUID, _OverrideUserID, ... }
|
|
105
113
|
*/
|
|
106
114
|
export async function checkSubmitErrors(result) {
|
|
107
115
|
const messages = result.messages || result.data?.Messages || [];
|
|
108
116
|
const allText = messages.filter(m => typeof m === 'string').join(' ');
|
|
109
117
|
|
|
110
|
-
|
|
118
|
+
// --- Ticket error detection (new interactive handling) ---
|
|
119
|
+
const hasTicketError = allText.includes('ticket_error');
|
|
120
|
+
const hasRepoMismatch = allText.includes('repo_mismatch');
|
|
121
|
+
|
|
122
|
+
if (hasTicketError) {
|
|
123
|
+
return await handleTicketError(allText);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (hasRepoMismatch) {
|
|
127
|
+
return await handleRepoMismatch(allText);
|
|
128
|
+
}
|
|
111
129
|
|
|
112
|
-
//
|
|
130
|
+
// --- Legacy ticket and user identity handling ---
|
|
131
|
+
const needsTicket = allText.includes('ticket_lookup_required_error');
|
|
113
132
|
const matchedUserPattern = USER_ID_PATTERNS.find(p => allText.includes(p));
|
|
114
133
|
const needsUser = !!matchedUserPattern;
|
|
115
134
|
const needsUserUid = matchedUserPattern && matchedUserPattern.includes('UID');
|
|
116
|
-
const needsUserId = matchedUserPattern && !needsUserUid;
|
|
117
135
|
|
|
118
136
|
if (!needsTicket && !needsUser) return null;
|
|
119
137
|
|
|
@@ -126,7 +144,6 @@ export async function checkSubmitErrors(result) {
|
|
|
126
144
|
log.dim(' Your session may have expired, or you may not be logged in.');
|
|
127
145
|
log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
|
|
128
146
|
|
|
129
|
-
// Check for stored user info from a previous login
|
|
130
147
|
const stored = await loadUserInfo();
|
|
131
148
|
const storedValue = needsUserUid ? stored.userUid : (stored.userId || stored.userUid);
|
|
132
149
|
const storedLabel = needsUserUid
|
|
@@ -176,11 +193,9 @@ export async function checkSubmitErrors(result) {
|
|
|
176
193
|
|
|
177
194
|
if (answers.ticketId) retryParams['_OverrideTicketID'] = answers.ticketId.trim();
|
|
178
195
|
|
|
179
|
-
// Resolve user identity from choice or direct input
|
|
180
196
|
const userValue = answers.userValue
|
|
181
197
|
|| (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
|
|
182
198
|
if (userValue) {
|
|
183
|
-
// Use the appropriate override param based on what the server asked for
|
|
184
199
|
if (needsUserUid) {
|
|
185
200
|
retryParams['_OverrideUserUID'] = userValue.trim();
|
|
186
201
|
} else {
|
|
@@ -191,6 +206,162 @@ export async function checkSubmitErrors(result) {
|
|
|
191
206
|
return retryParams;
|
|
192
207
|
}
|
|
193
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Handle ticket_error: Record update requires a Ticket ID but none was provided.
|
|
211
|
+
* Prompts the user with 4 recovery options.
|
|
212
|
+
*/
|
|
213
|
+
async function handleTicketError(allText) {
|
|
214
|
+
const inquirer = (await import('inquirer')).default;
|
|
215
|
+
|
|
216
|
+
// Try to extract record details from error text
|
|
217
|
+
const entityMatch = allText.match(/entity:(\w+)/);
|
|
218
|
+
const rowIdMatch = allText.match(/RowID:(\d+)/);
|
|
219
|
+
const uidMatch = allText.match(/UID:([a-zA-Z0-9]+)/);
|
|
220
|
+
const entity = entityMatch?.[1];
|
|
221
|
+
const rowId = rowIdMatch?.[1];
|
|
222
|
+
const uid = uidMatch?.[1];
|
|
223
|
+
|
|
224
|
+
log.warn('This record update requires a Ticket ID.');
|
|
225
|
+
|
|
226
|
+
const answers = await inquirer.prompt([
|
|
227
|
+
{
|
|
228
|
+
type: 'list',
|
|
229
|
+
name: 'ticketAction',
|
|
230
|
+
message: 'Record update requires a Ticket ID:',
|
|
231
|
+
choices: [
|
|
232
|
+
{ name: 'Apply a Ticket ID to this record and resubmit', value: 'apply_one' },
|
|
233
|
+
{ name: 'Apply a Ticket ID to all updates in this transaction, and update my current Ticket ID reference', value: 'apply_all' },
|
|
234
|
+
{ name: 'Skip this record update', value: 'skip_one' },
|
|
235
|
+
{ name: 'Skip all updates that require a Ticket ID', value: 'skip_all' },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: 'input',
|
|
240
|
+
name: 'ticketId',
|
|
241
|
+
message: 'Enter Ticket ID:',
|
|
242
|
+
when: (a) => a.ticketAction === 'apply_one' || a.ticketAction === 'apply_all',
|
|
243
|
+
validate: v => v.trim() ? true : 'Ticket ID is required',
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
if (answers.ticketAction === 'skip_one') {
|
|
248
|
+
return { skipRecord: true };
|
|
249
|
+
}
|
|
250
|
+
if (answers.ticketAction === 'skip_all') {
|
|
251
|
+
return { skipAll: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ticketId = answers.ticketId.trim();
|
|
255
|
+
const ticketExpressions = [];
|
|
256
|
+
|
|
257
|
+
if (entity && rowId) {
|
|
258
|
+
ticketExpressions.push(buildTicketExpression(entity, rowId, ticketId));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Store ticket based on scope
|
|
262
|
+
if (answers.ticketAction === 'apply_all') {
|
|
263
|
+
await setGlobalTicket(ticketId);
|
|
264
|
+
log.dim(` Stored ticket "${ticketId}" for all future submissions`);
|
|
265
|
+
} else if (uid) {
|
|
266
|
+
await setRecordTicket(uid, rowId, entity, ticketId);
|
|
267
|
+
log.dim(` Stored ticket "${ticketId}" for record ${uid}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
retryParams: { '_OverrideTicketID': ticketId },
|
|
272
|
+
ticketExpressions,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Handle repo_mismatch: The provided Ticket ID belongs to a different repository.
|
|
278
|
+
* Prompts the user with 6 recovery options.
|
|
279
|
+
*/
|
|
280
|
+
async function handleRepoMismatch(allText) {
|
|
281
|
+
const inquirer = (await import('inquirer')).default;
|
|
282
|
+
|
|
283
|
+
// Try to extract ticket ID from error text
|
|
284
|
+
const ticketMatch = allText.match(/Ticket(?:\s+ID)?\s+(?:of\s+)?([A-Za-z0-9_-]+)/i);
|
|
285
|
+
const ticketId = ticketMatch?.[1] || 'unknown';
|
|
286
|
+
|
|
287
|
+
// Try to extract record details
|
|
288
|
+
const entityMatch = allText.match(/entity:(\w+)/);
|
|
289
|
+
const rowIdMatch = allText.match(/RowID:(\d+)/);
|
|
290
|
+
const uidMatch = allText.match(/UID:([a-zA-Z0-9]+)/);
|
|
291
|
+
const entity = entityMatch?.[1];
|
|
292
|
+
const rowId = rowIdMatch?.[1];
|
|
293
|
+
const uid = uidMatch?.[1];
|
|
294
|
+
|
|
295
|
+
log.warn(`Ticket "${ticketId}" is for another repository.`);
|
|
296
|
+
|
|
297
|
+
const answers = await inquirer.prompt([
|
|
298
|
+
{
|
|
299
|
+
type: 'list',
|
|
300
|
+
name: 'repoAction',
|
|
301
|
+
message: `The Ticket ID of "${ticketId}" is for another Repository:`,
|
|
302
|
+
choices: [
|
|
303
|
+
{ name: 'Commit anyway', value: 'commit_one' },
|
|
304
|
+
{ name: 'Submit with another Ticket ID', value: 'change_one' },
|
|
305
|
+
{ name: 'Skip this record', value: 'skip_one' },
|
|
306
|
+
{ name: 'Commit all transactions with this ID anyway', value: 'commit_all' },
|
|
307
|
+
{ name: 'Commit all transactions with another Ticket ID, and update my current Ticket ID reference', value: 'change_all' },
|
|
308
|
+
{ name: 'Skip all', value: 'skip_all' },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
type: 'input',
|
|
313
|
+
name: 'newTicketId',
|
|
314
|
+
message: 'Enter new Ticket ID:',
|
|
315
|
+
when: (a) => a.repoAction === 'change_one' || a.repoAction === 'change_all',
|
|
316
|
+
validate: v => v.trim() ? true : 'Ticket ID is required',
|
|
317
|
+
},
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
if (answers.repoAction === 'skip_one') {
|
|
321
|
+
return { skipRecord: true };
|
|
322
|
+
}
|
|
323
|
+
if (answers.repoAction === 'skip_all') {
|
|
324
|
+
return { skipAll: true };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// "Commit anyway" options — retry with same ticket
|
|
328
|
+
if (answers.repoAction === 'commit_one') {
|
|
329
|
+
return {
|
|
330
|
+
retryParams: { '_OverrideTicketID': ticketId },
|
|
331
|
+
ticketExpressions: [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (answers.repoAction === 'commit_all') {
|
|
335
|
+
await setGlobalTicket(ticketId);
|
|
336
|
+
log.dim(` Will use ticket "${ticketId}" for all remaining submissions`);
|
|
337
|
+
return {
|
|
338
|
+
retryParams: { '_OverrideTicketID': ticketId },
|
|
339
|
+
ticketExpressions: [],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// "Change ticket" options
|
|
344
|
+
const newTicketId = answers.newTicketId.trim();
|
|
345
|
+
const ticketExpressions = [];
|
|
346
|
+
|
|
347
|
+
if (entity && rowId) {
|
|
348
|
+
ticketExpressions.push(buildTicketExpression(entity, rowId, newTicketId));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (answers.repoAction === 'change_all') {
|
|
352
|
+
await setGlobalTicket(newTicketId);
|
|
353
|
+
log.dim(` Stored ticket "${newTicketId}" for all future submissions`);
|
|
354
|
+
} else if (uid) {
|
|
355
|
+
await setRecordTicket(uid, rowId, entity, newTicketId);
|
|
356
|
+
log.dim(` Stored ticket "${newTicketId}" for record ${uid}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
retryParams: { '_OverrideTicketID': newTicketId },
|
|
361
|
+
ticketExpressions,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
194
365
|
/**
|
|
195
366
|
* Parse file arguments in the format: field=@path or just @path
|
|
196
367
|
* Returns objects suitable for multipart upload.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { log } from './logger.js';
|
|
4
|
+
|
|
5
|
+
const DBO_DIR = '.dbo';
|
|
6
|
+
const TICKETING_FILE = 'ticketing.local.json';
|
|
7
|
+
|
|
8
|
+
function dboDir() {
|
|
9
|
+
return join(process.cwd(), DBO_DIR);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ticketingPath() {
|
|
13
|
+
return join(dboDir(), TICKETING_FILE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TICKETING = { ticket_id: null, records: [] };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load ticketing.local.json. Returns default structure if missing or corrupted.
|
|
20
|
+
*/
|
|
21
|
+
export async function loadTicketing() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(ticketingPath(), 'utf8');
|
|
24
|
+
const data = JSON.parse(raw);
|
|
25
|
+
return {
|
|
26
|
+
ticket_id: data.ticket_id || null,
|
|
27
|
+
records: Array.isArray(data.records) ? data.records : [],
|
|
28
|
+
};
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== 'ENOENT') {
|
|
31
|
+
log.warn('Ticketing config is corrupted or unreadable — starting fresh.');
|
|
32
|
+
}
|
|
33
|
+
return { ...DEFAULT_TICKETING, records: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Save ticketing.local.json.
|
|
39
|
+
*/
|
|
40
|
+
export async function saveTicketing(data) {
|
|
41
|
+
await mkdir(dboDir(), { recursive: true });
|
|
42
|
+
await writeFile(ticketingPath(), JSON.stringify(data, null, 2) + '\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the global ticket_id, or null if not set.
|
|
47
|
+
*/
|
|
48
|
+
export async function getGlobalTicket() {
|
|
49
|
+
const data = await loadTicketing();
|
|
50
|
+
return data.ticket_id || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the per-record ticket for a specific UID, or null.
|
|
55
|
+
*/
|
|
56
|
+
export async function getRecordTicket(uid) {
|
|
57
|
+
const data = await loadTicketing();
|
|
58
|
+
const record = data.records.find(r => r.UID === uid);
|
|
59
|
+
return record ? record.ticket_id : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Set the global ticket_id.
|
|
64
|
+
*/
|
|
65
|
+
export async function setGlobalTicket(ticketId) {
|
|
66
|
+
const data = await loadTicketing();
|
|
67
|
+
data.ticket_id = ticketId;
|
|
68
|
+
await saveTicketing(data);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Store a per-record ticket entry. Deduplicates by UID.
|
|
73
|
+
*/
|
|
74
|
+
export async function setRecordTicket(uid, rowId, entity, ticketId) {
|
|
75
|
+
const data = await loadTicketing();
|
|
76
|
+
const idx = data.records.findIndex(r => r.UID === uid);
|
|
77
|
+
const entry = {
|
|
78
|
+
UID: uid,
|
|
79
|
+
RowID: rowId,
|
|
80
|
+
entity,
|
|
81
|
+
ticket_id: ticketId,
|
|
82
|
+
expression: buildTicketExpression(entity, rowId, ticketId),
|
|
83
|
+
};
|
|
84
|
+
if (idx >= 0) {
|
|
85
|
+
data.records[idx] = entry;
|
|
86
|
+
} else {
|
|
87
|
+
data.records.push(entry);
|
|
88
|
+
}
|
|
89
|
+
await saveTicketing(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Clear the global ticket_id (preserves records).
|
|
94
|
+
*/
|
|
95
|
+
export async function clearGlobalTicket() {
|
|
96
|
+
const data = await loadTicketing();
|
|
97
|
+
data.ticket_id = null;
|
|
98
|
+
await saveTicketing(data);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remove a specific per-record ticket by UID.
|
|
103
|
+
*/
|
|
104
|
+
export async function clearRecordTicket(uid) {
|
|
105
|
+
const data = await loadTicketing();
|
|
106
|
+
const before = data.records.length;
|
|
107
|
+
data.records = data.records.filter(r => r.UID !== uid);
|
|
108
|
+
if (data.records.length !== before) {
|
|
109
|
+
await saveTicketing(data);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Clear all per-record tickets (preserves global ticket_id).
|
|
115
|
+
*/
|
|
116
|
+
export async function clearAllRecordTickets() {
|
|
117
|
+
const data = await loadTicketing();
|
|
118
|
+
if (data.records.length > 0) {
|
|
119
|
+
data.records = [];
|
|
120
|
+
await saveTicketing(data);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a ticket column expression for DBO input syntax.
|
|
126
|
+
*/
|
|
127
|
+
export function buildTicketExpression(entity, rowId, ticketId) {
|
|
128
|
+
return `RowID:${rowId};column:${entity}._LastUpdatedTicketID=${ticketId}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check for a stored ticket before batch submission and prompt the user.
|
|
133
|
+
* Returns { useTicket, clearTicket, cancel }.
|
|
134
|
+
*
|
|
135
|
+
* @param {Object} options - Command options (checks options.ticket for flag override)
|
|
136
|
+
*/
|
|
137
|
+
export async function checkStoredTicket(options) {
|
|
138
|
+
// --ticket flag takes precedence; skip stored-ticket prompt
|
|
139
|
+
if (options.ticket) {
|
|
140
|
+
return { useTicket: false, clearTicket: false, cancel: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data = await loadTicketing();
|
|
144
|
+
if (!data.ticket_id) {
|
|
145
|
+
return { useTicket: false, clearTicket: false, cancel: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const inquirer = (await import('inquirer')).default;
|
|
149
|
+
const { action } = await inquirer.prompt([{
|
|
150
|
+
type: 'list',
|
|
151
|
+
name: 'action',
|
|
152
|
+
message: `Use stored Ticket ID "${data.ticket_id}" for this submission?`,
|
|
153
|
+
choices: [
|
|
154
|
+
{ name: `Yes, use "${data.ticket_id}"`, value: 'use' },
|
|
155
|
+
{ name: 'No, clear stored ticket', value: 'clear' },
|
|
156
|
+
{ name: 'Cancel submission', value: 'cancel' },
|
|
157
|
+
],
|
|
158
|
+
}]);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
useTicket: action === 'use',
|
|
162
|
+
clearTicket: action === 'clear',
|
|
163
|
+
cancel: action === 'cancel',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Apply a stored ticket to submission data expressions if no --ticket flag is set.
|
|
169
|
+
* Checks per-record ticket first, then global ticket.
|
|
170
|
+
*
|
|
171
|
+
* @param {string[]} dataExprs - The data expressions array (mutated in place)
|
|
172
|
+
* @param {string} entity - Entity name
|
|
173
|
+
* @param {string|number} rowId - Row ID or UID used in the submission
|
|
174
|
+
* @param {string} uid - Record UID for per-record lookup
|
|
175
|
+
* @param {Object} options - Command options
|
|
176
|
+
*/
|
|
177
|
+
export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, uid, options) {
|
|
178
|
+
if (options.ticket) return; // --ticket flag takes precedence
|
|
179
|
+
|
|
180
|
+
const recordTicket = await getRecordTicket(uid);
|
|
181
|
+
const globalTicket = await getGlobalTicket();
|
|
182
|
+
const ticketToUse = recordTicket || globalTicket;
|
|
183
|
+
|
|
184
|
+
if (ticketToUse) {
|
|
185
|
+
const ticketExpr = buildTicketExpression(entity, rowId, ticketToUse);
|
|
186
|
+
dataExprs.push(ticketExpr);
|
|
187
|
+
log.dim(` Applying ticket: ${ticketToUse}`);
|
|
188
|
+
}
|
|
189
|
+
}
|