@dboio/cli 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,11 +128,12 @@ All configuration is **directory-scoped**. Each project folder maintains its own
128
128
  |------|---------|-----|
129
129
  | `config.json` | Domain, app metadata, placement preferences | Committable (shared) |
130
130
  | `config.local.json` | Per-user settings: plugin scopes, future user prefs | Gitignored (per-user) |
131
+ | `ticketing.local.json` | Stored ticket IDs for submission error recovery | Gitignored (per-user) |
131
132
  | `credentials.json` | Username, user ID, UID, name, email (no password) | Gitignored (per-user) |
132
133
  | `cookies.txt` | Session cookie (Netscape format) | Gitignored (per-user) |
133
134
  | `structure.json` | Bin directory mapping (created by `dbo clone`) | Committable (shared) |
134
135
 
135
- `dbo init` automatically adds `.dbo/credentials.json`, `.dbo/cookies.txt`, and `.dbo/config.local.json` to `.gitignore` (creates the file if it doesn't exist).
136
+ `dbo init` automatically adds `.dbo/credentials.json`, `.dbo/cookies.txt`, `.dbo/config.local.json`, and `.dbo/ticketing.local.json` to `.gitignore` (creates the file if it doesn't exist).
136
137
 
137
138
  #### config.json reference
138
139
 
@@ -1090,7 +1091,35 @@ The `add` and `push` commands never submit these server-managed columns:
1090
1091
 
1091
1092
  The `input`, `push`, and `add` commands automatically detect recoverable server errors and prompt for missing values instead of failing immediately.
1092
1093
 
1093
- #### Ticket ID required
1094
+ #### Ticket error recovery
1095
+
1096
+ When the server returns a `ticket_error` (record update requires a Ticket ID), the CLI prompts with interactive recovery options:
1097
+
1098
+ ```
1099
+ ⚠ This record update requires a Ticket ID.
1100
+ ? Record update requires a Ticket ID:
1101
+ ❯ Apply a Ticket ID to this record and resubmit
1102
+ Apply a Ticket ID to all updates in this transaction, and update my current Ticket ID reference
1103
+ Skip this record update
1104
+ Skip all updates that require a Ticket ID
1105
+ ```
1106
+
1107
+ When the server returns a `repo_mismatch` (Ticket ID belongs to a different repository), the CLI prompts:
1108
+
1109
+ ```
1110
+ ⚠ Ticket "TICKET-123" is for another repository.
1111
+ ? The Ticket ID of "TICKET-123" is for another Repository:
1112
+ ❯ Commit anyway
1113
+ Submit with another Ticket ID
1114
+ Skip this record
1115
+ Commit all transactions with this ID anyway
1116
+ Commit all transactions with another Ticket ID, and update my current Ticket ID reference
1117
+ Skip all
1118
+ ```
1119
+
1120
+ Ticket selections are stored in `.dbo/ticketing.local.json` for reuse across submissions. Per-record tickets are cleaned up after successful submission; the global ticket persists until explicitly cleared. The `--ticket` flag always takes precedence over stored tickets.
1121
+
1122
+ #### Ticket ID required (legacy)
1094
1123
 
1095
1124
  When the server returns `ticket_lookup_required_error`, the CLI prompts:
1096
1125
 
@@ -1101,6 +1130,40 @@ When the server returns `ticket_lookup_required_error`, the CLI prompts:
1101
1130
 
1102
1131
  The submission is then retried with `_OverrideTicketID`. To skip the prompt, pass `--ticket <id>` upfront.
1103
1132
 
1133
+ #### Pre-submission ticket prompt
1134
+
1135
+ When a stored ticket exists in `.dbo/ticketing.local.json`, the CLI prompts before batch submissions:
1136
+
1137
+ ```
1138
+ ? Use stored Ticket ID "TICKET-123" for this submission?
1139
+ ❯ Yes, use "TICKET-123"
1140
+ No, clear stored ticket
1141
+ Cancel submission
1142
+ ```
1143
+
1144
+ #### `.dbo/ticketing.local.json`
1145
+
1146
+ Stores ticket IDs for automatic application during submissions:
1147
+
1148
+ ```json
1149
+ {
1150
+ "ticket_id": "TICKET-123",
1151
+ "records": [
1152
+ {
1153
+ "UID": "a2dxvg23rk6xsmnum7pdxa",
1154
+ "RowID": 16012,
1155
+ "entity": "content",
1156
+ "ticket_id": "TICKET-456",
1157
+ "expression": "RowID:16012;column:content._LastUpdatedTicketID=TICKET-456"
1158
+ }
1159
+ ]
1160
+ }
1161
+ ```
1162
+
1163
+ - `ticket_id` — Global ticket applied to all submissions until cleared
1164
+ - `records` — Per-record tickets (auto-cleared after successful submission)
1165
+ - `--ticket` flag always takes precedence over stored tickets
1166
+
1104
1167
  #### User identity required
1105
1168
 
1106
1169
  When the server returns an error mentioning `LoggedInUser_UID`, `LoggedInUserID`, `CurrentUserID`, or `UserID`, the CLI checks for a stored user identity from `dbo login`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 { loadAppConfig } from '../lib/config.js';
10
+ import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
10
11
 
11
12
  // Directories and patterns to skip when scanning with `dbo add .`
12
13
  const IGNORE_DIRS = new Set(['.dbo', '.git', 'node_modules', '.svn', '.hg']);
@@ -245,10 +246,21 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
245
246
  let body = await buildInputBody(dataExprs, extraParams);
246
247
  let result = await client.postUrlEncoded('/api/input/submit', body);
247
248
 
248
- // Retry with prompted params if needed (ticket, user)
249
- const retryParams = await checkSubmitErrors(result);
250
- if (retryParams) {
251
- Object.assign(extraParams, retryParams);
249
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
250
+ const retryResult = await checkSubmitErrors(result);
251
+ if (retryResult) {
252
+ if (retryResult.skipRecord) {
253
+ log.warn(' Skipping record');
254
+ return null;
255
+ }
256
+ if (retryResult.skipAll) {
257
+ throw new Error('SKIP_ALL');
258
+ }
259
+ if (retryResult.ticketExpressions?.length > 0) {
260
+ dataExprs.push(...retryResult.ticketExpressions);
261
+ }
262
+ const params = retryResult.retryParams || retryResult;
263
+ Object.assign(extraParams, params);
252
264
  body = await buildInputBody(dataExprs, extraParams);
253
265
  result = await client.postUrlEncoded('/api/input/submit', body);
254
266
  }
@@ -302,6 +314,19 @@ async function addDirectory(dirPath, client, options) {
302
314
  if (!proceed) return;
303
315
  }
304
316
 
317
+ // Pre-flight ticket validation
318
+ if (!options.ticket) {
319
+ const ticketCheck = await checkStoredTicket(options);
320
+ if (ticketCheck.cancel) {
321
+ log.info('Submission cancelled');
322
+ return;
323
+ }
324
+ if (ticketCheck.clearTicket) {
325
+ await clearGlobalTicket();
326
+ log.dim(' Cleared stored ticket');
327
+ }
328
+ }
329
+
305
330
  let succeeded = 0;
306
331
  let failed = 0;
307
332
  let batchDefaults = null;
@@ -314,6 +339,10 @@ async function addDirectory(dirPath, client, options) {
314
339
  succeeded++;
315
340
  }
316
341
  } catch (err) {
342
+ if (err.message === 'SKIP_ALL') {
343
+ log.info('Skipping remaining records');
344
+ break;
345
+ }
317
346
  log.error(`Failed: ${relative(process.cwd(), filePath)} — ${err.message}`);
318
347
  failed++;
319
348
  }
@@ -62,7 +62,7 @@ export const initCommand = new Command('init')
62
62
  }
63
63
 
64
64
  // Ensure sensitive files are gitignored
65
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json']);
65
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
66
66
 
67
67
  log.success(`Initialized .dbo/ for ${domain}`);
68
68
  log.dim(' Run "dbo login" to authenticate.');
@@ -3,6 +3,7 @@ import { DboClient } from '../lib/client.js';
3
3
  import { buildInputBody, parseFileArg, checkSubmitErrors } from '../lib/input-parser.js';
4
4
  import { formatResponse, formatError } from '../lib/formatter.js';
5
5
  import { loadAppConfig } from '../lib/config.js';
6
+ import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
6
7
  import { log } from '../lib/logger.js';
7
8
 
8
9
  function collect(value, previous) {
@@ -31,6 +32,19 @@ export const inputCommand = new Command('input')
31
32
  if (options.login) extraParams['_login'] = 'true';
32
33
  if (options.transactional) extraParams['_transactional'] = 'true';
33
34
 
35
+ // Pre-flight ticket validation
36
+ if (!options.ticket) {
37
+ const ticketCheck = await checkStoredTicket(options);
38
+ if (ticketCheck.cancel) {
39
+ log.info('Submission cancelled');
40
+ return;
41
+ }
42
+ if (ticketCheck.clearTicket) {
43
+ await clearGlobalTicket();
44
+ log.dim(' Cleared stored ticket');
45
+ }
46
+ }
47
+
34
48
  // Check if data expressions include AppID; if not and config has one, prompt
35
49
  const allDataText = options.data.join(' ');
36
50
  const hasAppId = /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
@@ -79,10 +93,15 @@ export const inputCommand = new Command('input')
79
93
  const files = options.file.map(parseFileArg);
80
94
  let result = await client.postMultipart('/api/input/submit', fields, files);
81
95
 
82
- // Retry with prompted params if needed (ticket, user)
83
- const retryParams = await checkSubmitErrors(result);
84
- if (retryParams) {
85
- Object.assign(fields, retryParams);
96
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
97
+ const retryResult = await checkSubmitErrors(result);
98
+ if (retryResult) {
99
+ if (retryResult.skipRecord || retryResult.skipAll) {
100
+ log.warn('Skipping submission');
101
+ return;
102
+ }
103
+ const params = retryResult.retryParams || retryResult;
104
+ Object.assign(fields, params);
86
105
  result = await client.postMultipart('/api/input/submit', fields, files);
87
106
  }
88
107
 
@@ -93,10 +112,15 @@ export const inputCommand = new Command('input')
93
112
  let body = await buildInputBody(options.data, extraParams);
94
113
  let result = await client.postUrlEncoded('/api/input/submit', body);
95
114
 
96
- // Retry with prompted params if needed (ticket, user)
97
- const retryParams = await checkSubmitErrors(result);
98
- if (retryParams) {
99
- Object.assign(extraParams, retryParams);
115
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
116
+ const retryResult = await checkSubmitErrors(result);
117
+ if (retryResult) {
118
+ if (retryResult.skipRecord || retryResult.skipAll) {
119
+ log.warn('Skipping submission');
120
+ return;
121
+ }
122
+ const params = retryResult.retryParams || retryResult;
123
+ Object.assign(extraParams, params);
100
124
  body = await buildInputBody(options.data, extraParams);
101
125
  result = await client.postUrlEncoded('/api/input/submit', body);
102
126
  }
@@ -48,7 +48,7 @@ export const loginCommand = new Command('login')
48
48
  log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
49
49
 
50
50
  // Ensure sensitive files are gitignored
51
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt']);
51
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
52
52
 
53
53
  // Fetch current user info to store ID and UID for future submissions
54
54
  try {
@@ -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 retryParams = await checkSubmitErrors(result);
72
- if (retryParams) {
73
- Object.assign(extraParams, retryParams);
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) {
@@ -217,6 +224,19 @@ async function pushDirectory(dirPath, client, options) {
217
224
  return;
218
225
  }
219
226
 
227
+ // Pre-flight ticket validation (only if no --ticket flag)
228
+ if (!options.ticket && toPush.length > 0) {
229
+ const ticketCheck = await checkStoredTicket(options);
230
+ if (ticketCheck.cancel) {
231
+ log.info('Submission cancelled');
232
+ return;
233
+ }
234
+ if (ticketCheck.clearTicket) {
235
+ await clearGlobalTicket();
236
+ log.dim(' Cleared stored ticket');
237
+ }
238
+ }
239
+
220
240
  // Group by entity and apply dependency ordering
221
241
  const byEntity = {};
222
242
  for (const item of toPush) {
@@ -240,6 +260,10 @@ async function pushDirectory(dirPath, client, options) {
240
260
  failed++;
241
261
  }
242
262
  } catch (err) {
263
+ if (err.message === 'SKIP_ALL') {
264
+ log.info('Skipping remaining records');
265
+ break;
266
+ }
243
267
  log.error(`Failed: ${item.metaPath} — ${err.message}`);
244
268
  failed++;
245
269
  }
@@ -321,16 +345,36 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
321
345
  const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
322
346
  log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) — ${fieldLabel}`);
323
347
 
348
+ // Apply stored ticket if no --ticket flag
349
+ await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options);
350
+
324
351
  const extraParams = { '_confirm': options.confirm };
325
352
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
326
353
 
327
354
  let body = await buildInputBody(dataExprs, extraParams);
328
355
  let result = await client.postUrlEncoded('/api/input/submit', body);
329
356
 
330
- // Retry with prompted params if needed (ticket, user)
331
- const retryParams = await checkSubmitErrors(result);
332
- if (retryParams) {
333
- Object.assign(extraParams, retryParams);
357
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
358
+ const retryResult = await checkSubmitErrors(result);
359
+ if (retryResult) {
360
+ // Handle skip actions
361
+ if (retryResult.skipRecord) {
362
+ log.warn(' Skipping record');
363
+ return false;
364
+ }
365
+ if (retryResult.skipAll) {
366
+ throw new Error('SKIP_ALL');
367
+ }
368
+
369
+ // Append ticket expressions
370
+ if (retryResult.ticketExpressions?.length > 0) {
371
+ dataExprs.push(...retryResult.ticketExpressions);
372
+ }
373
+
374
+ // Merge retry params (new-style has retryParams nested, legacy is flat)
375
+ const params = retryResult.retryParams || retryResult;
376
+ Object.assign(extraParams, params);
377
+
334
378
  body = await buildInputBody(dataExprs, extraParams);
335
379
  result = await client.postUrlEncoded('/api/input/submit', body);
336
380
  }
@@ -347,6 +391,9 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
347
391
  return false;
348
392
  }
349
393
 
394
+ // Clean up per-record ticket on success
395
+ await clearRecordTicket(uid);
396
+
350
397
  // Update file timestamps from server response
351
398
  try {
352
399
  const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
@@ -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
- * - ticket_lookup_required_errorprompts for Ticket ID
100
+ * - ticket_errorinteractive 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 of extra params to add on retry, or null if no
104
- * recoverable errors were found.
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
- const needsTicket = allText.includes('ticket_lookup_required_error');
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
- // Detect which user identity variant is needed
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
+ }