@dboio/cli 0.6.10 → 0.6.12

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
@@ -618,6 +618,7 @@ dbo content deploy albain3dwkofbhnd1qtd1q assets/css/colors.css
618
618
  dbo content deploy rncjivlghu65bmkbjnxynq assets/js/app.js
619
619
  dbo content deploy abc123 docs/readme.md --ticket myTicket
620
620
  dbo content deploy abc123 test.html --confirm false # validate only
621
+ dbo content deploy abc123 image.png --multipart # binary file upload
621
622
  ```
622
623
 
623
624
  This is a shorthand for:
@@ -632,6 +633,7 @@ dbo input -d 'RowUID:albain3dwkofbhnd1qtd1q;column:content.Content@assets/css/co
632
633
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
633
634
  | `--ticket <id>` | Override ticket ID |
634
635
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
636
+ | `--multipart` | Use multipart/form-data upload (for binary files) |
635
637
 
636
638
  #### `dbo content pull`
637
639
 
@@ -1165,15 +1167,23 @@ The submission is then retried with `_OverrideTicketID`. To skip the prompt, pas
1165
1167
 
1166
1168
  #### Pre-submission ticket prompt
1167
1169
 
1168
- When a stored ticket exists in `.dbo/ticketing.local.json`, the CLI prompts before batch submissions:
1170
+ When a stored ticket exists in `.dbo/ticketing.local.json`, the CLI prompts before batch submissions (`push`, `input`, `add`, `content deploy`, `deploy`):
1169
1171
 
1170
1172
  ```
1171
1173
  ? Use stored Ticket ID "TICKET-123" for this submission?
1172
1174
  ❯ Yes, use "TICKET-123"
1175
+ Use a different ticket for this submission only
1176
+ Use a different ticket for this and future submissions
1173
1177
  No, clear stored ticket
1174
1178
  Cancel submission
1175
1179
  ```
1176
1180
 
1181
+ - **Yes** — applies the stored ticket to all records in this submission
1182
+ - **Different ticket (this submission only)** — prompts for a ticket ID, uses it for all records in this invocation without updating `ticketing.local.json`
1183
+ - **Different ticket (this and future)** — prompts for a ticket ID, updates `ticketing.local.json`, and uses it for this and all future submissions
1184
+ - **No, clear** — removes the stored `ticket_id` and proceeds without a ticket
1185
+ - **Cancel** — aborts the submission
1186
+
1177
1187
  #### `.dbo/ticketing.local.json`
1178
1188
 
1179
1189
  Stores ticket IDs for automatic application during submissions:
@@ -1327,6 +1337,11 @@ dbo deploy css:colors --confirm false
1327
1337
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1328
1338
  | `--ticket <id>` | Override ticket ID |
1329
1339
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1340
+ | `--json` | Output raw JSON |
1341
+ | `-v, --verbose` | Show HTTP request details |
1342
+ | `--domain <host>` | Override domain |
1343
+
1344
+ The `deploy` command includes the same automatic error recovery as `push`, `input`, and `add` — including ticket error handling, repository mismatch recovery, user identity prompts, and ModifyKey protection. When a stored ticket exists, you'll be prompted before the first submission (see [Pre-submission ticket prompt](#pre-submission-ticket-prompt)).
1330
1345
 
1331
1346
  #### `dbo.deploy.json` manifest
1332
1347
 
@@ -1348,6 +1363,17 @@ Create a `dbo.deploy.json` file in your project root to define named deployments
1348
1363
  "file": "docs/colors.md",
1349
1364
  "entity": "extension",
1350
1365
  "column": "Text"
1366
+ },
1367
+ "img:logo": {
1368
+ "uid": "x9fk2m3npqrs7tuvwyz1ab",
1369
+ "file": "assets/images/logo.png",
1370
+ "multipart": true
1371
+ },
1372
+ "upload:icons": {
1373
+ "uid": "7ddf10982a96457fa4f440",
1374
+ "file": "assets/css/launchpad-icons.css",
1375
+ "multipart": true,
1376
+ "filename": "launchpad-icons.css"
1351
1377
  }
1352
1378
  }
1353
1379
  }
@@ -1357,11 +1383,40 @@ Create a `dbo.deploy.json` file in your project root to define named deployments
1357
1383
  |-------|-------------|---------|
1358
1384
  | `uid` | Target record UID (required) | — |
1359
1385
  | `file` | Local file path (required) | — |
1360
- | `entity` | Target entity name | `content` |
1361
- | `column` | Target column name | `Content` |
1386
+ | `entity` | Target entity name | `content` (`media` when `multipart: true`) |
1387
+ | `column` | Target column name | `Content` (`File` when `multipart: true`) |
1388
+ | `multipart` | Use multipart/form-data upload (for binary files) | `false` |
1389
+ | `filename` | Set the `Filename` column on the target record | basename of `file` |
1362
1390
 
1363
1391
  This replaces the curl commands typically embedded in `package.json` scripts.
1364
1392
 
1393
+ #### Non-interactive mode (npm scripts)
1394
+
1395
+ When running `dbo deploy` (or any submission command) inside npm scripts or piped commands where stdin is not a TTY, the CLI automatically skips all interactive prompts and uses stored credentials:
1396
+
1397
+ - **Stored ticket** — auto-applied from `.dbo/ticketing.local.json`
1398
+ - **ModifyKey** — auto-applied from `.dbo/config.json` (`AppModifyKey`)
1399
+ - **User identity** — auto-applied from `.dbo/credentials.json`
1400
+
1401
+ If a required value is missing and no interactive prompt is possible, the command fails with a clear error message explaining how to set up the missing value.
1402
+
1403
+ Example `package.json` scripts:
1404
+
1405
+ ```json
1406
+ {
1407
+ "scripts": {
1408
+ "deploy:css": "dbo deploy css:colors && dbo deploy css:layout",
1409
+ "deploy:docs": "dbo deploy doc:readme && dbo deploy doc:api",
1410
+ "deploy:all": "dbo deploy --all"
1411
+ }
1412
+ }
1413
+ ```
1414
+
1415
+ To set up for non-interactive use:
1416
+ 1. Run `dbo login` to store user credentials
1417
+ 2. Run `dbo clone` to store the ModifyKey (if applicable)
1418
+ 3. Set a ticket interactively once — it persists in `ticketing.local.json` for future npm script runs
1419
+
1365
1420
  ---
1366
1421
 
1367
1422
  ## Global Flags
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,10 +2,11 @@ import { Command } from 'commander';
2
2
  import { writeFile } from 'fs/promises';
3
3
  import chalk from 'chalk';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { buildInputBody } from '../lib/input-parser.js';
5
+ import { buildInputBody, checkSubmitErrors } 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
8
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
9
+ import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
9
10
  import { log } from '../lib/logger.js';
10
11
 
11
12
  function collect(value, previous) {
@@ -30,6 +31,7 @@ const deployCmd = new Command('deploy')
30
31
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
31
32
  .option('--ticket <id>', 'Override ticket ID')
32
33
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
34
+ .option('--multipart', 'Use multipart/form-data upload (for binary files)')
33
35
  .option('--json', 'Output raw JSON')
34
36
  .option('-v, --verbose', 'Show HTTP request details')
35
37
  .option('--domain <host>', 'Override domain')
@@ -44,21 +46,84 @@ const deployCmd = new Command('deploy')
44
46
  return;
45
47
  }
46
48
 
49
+ // Pre-flight ticket validation (only if no --ticket flag)
50
+ let sessionTicketOverride = null;
51
+ if (!options.ticket) {
52
+ const ticketCheck = await checkStoredTicket(options);
53
+ if (ticketCheck.cancel) {
54
+ log.info('Submission cancelled');
55
+ return;
56
+ }
57
+ if (ticketCheck.clearTicket) {
58
+ await clearGlobalTicket();
59
+ log.dim(' Cleared stored ticket');
60
+ }
61
+ if (ticketCheck.overrideTicket) {
62
+ sessionTicketOverride = ticketCheck.overrideTicket;
63
+ }
64
+ }
65
+
47
66
  const extraParams = { '_confirm': options.confirm };
48
67
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
49
68
  if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
50
69
 
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);
70
+ const isMultipart = options.multipart === true;
71
+ const dataExprs = isMultipart ? [] : [`RowUID:${uid};column:content.Content@${filepath}`];
72
+
73
+ // Apply stored ticket if no --ticket flag
74
+ if (!options.ticket) {
75
+ const storedTicket = sessionTicketOverride || await getRecordTicket(uid) || await getGlobalTicket();
76
+ if (storedTicket) {
77
+ extraParams['_OverrideTicketID'] = storedTicket;
78
+ }
79
+ }
80
+ await applyStoredTicketToSubmission(dataExprs, 'content', uid, uid, options, sessionTicketOverride);
81
+
82
+ // Submit helper — handles both URL-encoded and multipart modes
83
+ async function submit() {
84
+ if (isMultipart) {
85
+ const fields = { ...extraParams };
86
+ for (const expr of dataExprs) {
87
+ const eqIdx = expr.indexOf('=');
88
+ if (eqIdx !== -1) {
89
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
90
+ }
91
+ }
92
+ const files = [{
93
+ fieldName: `RowUID:${uid};column:content.Content`,
94
+ filePath: filepath,
95
+ fileName: filepath.split('/').pop(),
96
+ }];
97
+ return client.postMultipart('/api/input/submit', fields, files);
98
+ } else {
99
+ const body = await buildInputBody(dataExprs, extraParams);
100
+ return client.postUrlEncoded('/api/input/submit', body);
101
+ }
102
+ }
103
+
104
+ let result = await submit();
54
105
 
55
106
  // Reactive ModifyKey retry
56
107
  if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
57
108
  const retryMK = await handleModifyKeyError();
58
109
  if (retryMK.cancel) { log.info('Submission cancelled'); return; }
59
110
  extraParams['_modify_key'] = retryMK.modifyKey;
60
- body = await buildInputBody(dataExprs, extraParams);
61
- result = await client.postUrlEncoded('/api/input/submit', body);
111
+ result = await submit();
112
+ }
113
+
114
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
115
+ const errorResult = await checkSubmitErrors(result);
116
+ if (errorResult) {
117
+ if (errorResult.skipRecord || errorResult.skipAll) {
118
+ log.info('Submission cancelled');
119
+ return;
120
+ }
121
+ if (errorResult.ticketExpressions?.length > 0) {
122
+ dataExprs.push(...errorResult.ticketExpressions);
123
+ }
124
+ const params = errorResult.retryParams || errorResult;
125
+ Object.assign(extraParams, params);
126
+ result = await submit();
62
127
  }
63
128
 
64
129
  formatResponse(result, { json: options.json });
@@ -1,9 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import { readFile } from 'fs/promises';
3
3
  import { DboClient } from '../lib/client.js';
4
- import { buildInputBody } from '../lib/input-parser.js';
4
+ import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
5
5
  import { formatResponse, formatError } from '../lib/formatter.js';
6
6
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
7
+ import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
7
8
  import { log } from '../lib/logger.js';
8
9
 
9
10
  const MANIFEST_FILE = 'dbo.deploy.json';
@@ -61,16 +62,35 @@ export const deployCommand = new Command('deploy')
61
62
  }
62
63
  let activeModifyKey = modifyKeyResult.modifyKey;
63
64
 
65
+ // Pre-flight ticket validation (only if no --ticket flag)
66
+ let sessionTicketOverride = null;
67
+ if (!options.ticket) {
68
+ const ticketCheck = await checkStoredTicket(options);
69
+ if (ticketCheck.cancel) {
70
+ log.info('Submission cancelled');
71
+ return;
72
+ }
73
+ if (ticketCheck.clearTicket) {
74
+ await clearGlobalTicket();
75
+ log.dim(' Cleared stored ticket');
76
+ }
77
+ if (ticketCheck.overrideTicket) {
78
+ sessionTicketOverride = ticketCheck.overrideTicket;
79
+ }
80
+ }
81
+
64
82
  for (const [entryName, entry] of entries) {
65
83
  if (!entry) {
66
84
  log.warn(`Skipping unknown deployment: ${entryName}`);
67
85
  continue;
68
86
  }
69
87
 
70
- const entity = entry.entity || 'content';
71
- const column = entry.column || 'Content';
88
+ const isMultipart = entry.multipart === true;
89
+ const entity = entry.entity || (isMultipart ? 'media' : 'content');
90
+ const column = entry.column || (isMultipart ? 'File' : 'Content');
72
91
  const uid = entry.uid;
73
92
  const file = entry.file;
93
+ const filename = entry.filename || file.split('/').pop();
74
94
 
75
95
  if (!uid || !file) {
76
96
  log.warn(`Skipping "${entryName}": missing uid or file.`);
@@ -83,9 +103,42 @@ export const deployCommand = new Command('deploy')
83
103
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
84
104
  if (activeModifyKey) extraParams['_modify_key'] = activeModifyKey;
85
105
 
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);
106
+ const dataExprs = isMultipart
107
+ ? [`RowUID:${uid};column:${entity}.Filename=${filename}`]
108
+ : [`RowUID:${uid};column:${entity}.${column}@${file}`];
109
+
110
+ // Apply stored ticket if no --ticket flag
111
+ if (!options.ticket) {
112
+ const storedTicket = sessionTicketOverride || await getRecordTicket(uid) || await getGlobalTicket();
113
+ if (storedTicket) {
114
+ extraParams['_OverrideTicketID'] = storedTicket;
115
+ }
116
+ }
117
+ await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options, sessionTicketOverride);
118
+
119
+ // Submit helper — handles both URL-encoded and multipart modes
120
+ async function submit() {
121
+ if (isMultipart) {
122
+ const fields = { ...extraParams };
123
+ for (const expr of dataExprs) {
124
+ const eqIdx = expr.indexOf('=');
125
+ if (eqIdx !== -1) {
126
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
127
+ }
128
+ }
129
+ const files = [{
130
+ fieldName: `RowUID:${uid};column:${entity}.${column}`,
131
+ filePath: file,
132
+ fileName: file.split('/').pop(),
133
+ }];
134
+ return client.postMultipart('/api/input/submit', fields, files);
135
+ } else {
136
+ const body = await buildInputBody(dataExprs, extraParams);
137
+ return client.postUrlEncoded('/api/input/submit', body);
138
+ }
139
+ }
140
+
141
+ let result = await submit();
89
142
 
90
143
  // Reactive ModifyKey retry
91
144
  if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
@@ -93,15 +146,33 @@ export const deployCommand = new Command('deploy')
93
146
  if (retryMK.cancel) { log.info('Submission cancelled'); break; }
94
147
  activeModifyKey = retryMK.modifyKey;
95
148
  extraParams['_modify_key'] = activeModifyKey;
96
- body = await buildInputBody(dataExprs, extraParams);
97
- result = await client.postUrlEncoded('/api/input/submit', body);
149
+ result = await submit();
150
+ }
151
+
152
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
153
+ const errorResult = await checkSubmitErrors(result);
154
+ if (errorResult) {
155
+ if (errorResult.skipRecord) {
156
+ log.warn(` Skipping "${entryName}"`);
157
+ continue;
158
+ }
159
+ if (errorResult.skipAll) {
160
+ log.warn(` Skipping "${entryName}" and all remaining`);
161
+ break;
162
+ }
163
+ if (errorResult.ticketExpressions?.length > 0) {
164
+ dataExprs.push(...errorResult.ticketExpressions);
165
+ }
166
+ const params = errorResult.retryParams || errorResult;
167
+ Object.assign(extraParams, params);
168
+ result = await submit();
98
169
  }
99
170
 
100
171
  if (result.successful) {
101
172
  log.success(`${entryName} deployed`);
102
173
  } else {
103
174
  log.error(`${entryName} failed`);
104
- for (const msg of result.messages) log.label('Message', msg);
175
+ formatResponse(result, { json: options.json });
105
176
  if (!options.all) process.exit(1);
106
177
  }
107
178
  }
@@ -1,7 +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
+ import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket } from './ticketing.js';
5
5
 
6
6
  /**
7
7
  * Parse DBO input syntax and build form data.
@@ -136,9 +136,36 @@ export async function checkSubmitErrors(result) {
136
136
 
137
137
  if (!needsTicket && !needsUser) return null;
138
138
 
139
- const prompts = [];
140
139
  const retryParams = {};
141
140
 
141
+ // Non-interactive mode: use stored credentials automatically
142
+ if (!process.stdin.isTTY) {
143
+ if (needsUser) {
144
+ const stored = await loadUserInfo();
145
+ const storedValue = needsUserUid ? stored.userUid : (stored.userId || stored.userUid);
146
+ if (storedValue) {
147
+ log.info(`Using stored user ${needsUserUid ? 'UID' : 'ID'}: ${storedValue} (non-interactive mode)`);
148
+ if (needsUserUid) {
149
+ retryParams['_OverrideUserUID'] = storedValue;
150
+ } else {
151
+ retryParams['_OverrideUserID'] = storedValue;
152
+ }
153
+ } else {
154
+ log.error(`This operation requires an authenticated user (${matchedUserPattern}).`);
155
+ log.dim(' Run "dbo login" first, or use an interactive terminal.');
156
+ return null;
157
+ }
158
+ }
159
+ if (needsTicket) {
160
+ log.error('This operation requires a Ticket ID.');
161
+ log.dim(' Use --ticket <id> or run interactively first to set a ticket.');
162
+ return null;
163
+ }
164
+ return Object.keys(retryParams).length > 0 ? retryParams : null;
165
+ }
166
+
167
+ const prompts = [];
168
+
142
169
  if (needsUser) {
143
170
  const idType = needsUserUid ? 'UID' : 'ID';
144
171
  log.warn(`This operation requires an authenticated user (${matchedUserPattern}).`);
@@ -212,8 +239,6 @@ export async function checkSubmitErrors(result) {
212
239
  * Prompts the user with 4 recovery options.
213
240
  */
214
241
  async function handleTicketError(allText) {
215
- const inquirer = (await import('inquirer')).default;
216
-
217
242
  // Try to extract record details from error text
218
243
  const entityMatch = allText.match(/entity:(\w+)/);
219
244
  const rowIdMatch = allText.match(/RowID:(\d+)/);
@@ -222,8 +247,24 @@ async function handleTicketError(allText) {
222
247
  const rowId = rowIdMatch?.[1];
223
248
  const uid = uidMatch?.[1];
224
249
 
250
+ // Non-interactive mode: try stored ticket, otherwise fail with instructions
251
+ if (!process.stdin.isTTY) {
252
+ const storedTicket = (uid && await getRecordTicket(uid)) || await getGlobalTicket();
253
+ if (storedTicket) {
254
+ log.info(`Using stored ticket "${storedTicket}" (non-interactive mode)`);
255
+ return {
256
+ retryParams: { '_OverrideTicketID': storedTicket },
257
+ ticketExpressions: entity && rowId ? [buildTicketExpression(entity, rowId, storedTicket)] : [],
258
+ };
259
+ }
260
+ log.error('This record requires a Ticket ID but no stored ticket is available.');
261
+ log.dim(' Run interactively first to set a ticket, or use --ticket <id>');
262
+ return null;
263
+ }
264
+
225
265
  log.warn('This record update requires a Ticket ID.');
226
266
 
267
+ const inquirer = (await import('inquirer')).default;
227
268
  const answers = await inquirer.prompt([
228
269
  {
229
270
  type: 'list',
@@ -279,8 +320,6 @@ async function handleTicketError(allText) {
279
320
  * Prompts the user with 6 recovery options.
280
321
  */
281
322
  async function handleRepoMismatch(allText) {
282
- const inquirer = (await import('inquirer')).default;
283
-
284
323
  // Try to extract ticket ID from error text
285
324
  const ticketMatch = allText.match(/Ticket(?:\s+ID)?\s+(?:of\s+)?([A-Za-z0-9_-]+)/i);
286
325
  const ticketId = ticketMatch?.[1] || 'unknown';
@@ -293,8 +332,18 @@ async function handleRepoMismatch(allText) {
293
332
  const rowId = rowIdMatch?.[1];
294
333
  const uid = uidMatch?.[1];
295
334
 
335
+ // Non-interactive mode: commit anyway with the existing ticket
336
+ if (!process.stdin.isTTY) {
337
+ log.warn(`Ticket "${ticketId}" is for another repository — committing anyway (non-interactive mode)`);
338
+ return {
339
+ retryParams: { '_OverrideTicketID': ticketId },
340
+ ticketExpressions: [],
341
+ };
342
+ }
343
+
296
344
  log.warn(`Ticket "${ticketId}" is for another repository.`);
297
345
 
346
+ const inquirer = (await import('inquirer')).default;
298
347
  const answers = await inquirer.prompt([
299
348
  {
300
349
  type: 'list',
@@ -15,6 +15,12 @@ export async function checkModifyKey(options = {}) {
15
15
  const storedKey = await loadAppModifyKey();
16
16
  if (!storedKey) return { modifyKey: null, cancel: false };
17
17
 
18
+ // Non-interactive mode: use stored key automatically
19
+ if (!process.stdin.isTTY) {
20
+ log.info('Using stored ModifyKey (non-interactive mode)');
21
+ return { modifyKey: storedKey, cancel: false };
22
+ }
23
+
18
24
  log.warn('');
19
25
  log.warn(' ⚠ This app is locked (production mode). A ModifyKey is required to submit changes.');
20
26
  log.warn('');
@@ -61,6 +67,13 @@ export function isModifyKeyError(responseMessage) {
61
67
  * Returns { modifyKey: string } on success or { modifyKey: null, cancel: true } on cancel.
62
68
  */
63
69
  export async function handleModifyKeyError() {
70
+ // Non-interactive mode: cannot recover without user input
71
+ if (!process.stdin.isTTY) {
72
+ log.error('This app requires a ModifyKey but none is stored locally.');
73
+ log.dim(' Run "dbo clone" to fetch the key, or use --modify-key <key>');
74
+ return { modifyKey: null, cancel: true };
75
+ }
76
+
64
77
  log.warn('');
65
78
  log.warn(' ⚠ This app requires a ModifyKey. The key has not been set locally (try re-running `dbo clone`).');
66
79
  log.warn('');
@@ -21,7 +21,13 @@ const DEFAULT_TICKETING = { ticket_id: null, records: [] };
21
21
  export async function loadTicketing() {
22
22
  try {
23
23
  const raw = await readFile(ticketingPath(), 'utf8');
24
- const data = JSON.parse(raw);
24
+ const trimmed = raw.trim();
25
+ if (!trimmed) {
26
+ const defaults = { ...DEFAULT_TICKETING, records: [] };
27
+ await saveTicketing(defaults);
28
+ return defaults;
29
+ }
30
+ const data = JSON.parse(trimmed);
25
31
  return {
26
32
  ticket_id: data.ticket_id || null,
27
33
  records: Array.isArray(data.records) ? data.records : [],
@@ -30,7 +36,9 @@ export async function loadTicketing() {
30
36
  if (err.code !== 'ENOENT') {
31
37
  log.warn('Ticketing config is corrupted or unreadable — starting fresh.');
32
38
  }
33
- return { ...DEFAULT_TICKETING, records: [] };
39
+ const defaults = { ...DEFAULT_TICKETING, records: [] };
40
+ try { await saveTicketing(defaults); } catch { /* ignore write errors */ }
41
+ return defaults;
34
42
  }
35
43
  }
36
44
 
@@ -123,9 +131,12 @@ export async function clearAllRecordTickets() {
123
131
 
124
132
  /**
125
133
  * Build a ticket column expression for DBO input syntax.
134
+ * Uses RowUID when rowId is non-numeric (a UID string).
126
135
  */
127
136
  export function buildTicketExpression(entity, rowId, ticketId) {
128
- return `RowID:${rowId};column:${entity}._LastUpdatedTicketID=${ticketId}`;
137
+ const isNumeric = /^-?\d+$/.test(String(rowId));
138
+ const rowKey = isNumeric ? 'RowID' : 'RowUID';
139
+ return `${rowKey}:${rowId};column:${entity}._LastUpdatedTicketID=${ticketId}`;
129
140
  }
130
141
 
131
142
  /**
@@ -145,6 +156,12 @@ export async function checkStoredTicket(options) {
145
156
  return { useTicket: false, clearTicket: false, cancel: false };
146
157
  }
147
158
 
159
+ // Non-interactive mode: auto-use stored ticket
160
+ if (!process.stdin.isTTY) {
161
+ log.info(`Using stored ticket "${data.ticket_id}" (non-interactive mode)`);
162
+ return { useTicket: true, clearTicket: false, cancel: false };
163
+ }
164
+
148
165
  const inquirer = (await import('inquirer')).default;
149
166
  const { action } = await inquirer.prompt([{
150
167
  type: 'list',
@@ -152,11 +169,31 @@ export async function checkStoredTicket(options) {
152
169
  message: `Use stored Ticket ID "${data.ticket_id}" for this submission?`,
153
170
  choices: [
154
171
  { name: `Yes, use "${data.ticket_id}"`, value: 'use' },
172
+ { name: 'Use a different ticket for this submission only', value: 'alt_once' },
173
+ { name: 'Use a different ticket for this and future submissions', value: 'alt_save' },
155
174
  { name: 'No, clear stored ticket', value: 'clear' },
156
175
  { name: 'Cancel submission', value: 'cancel' },
157
176
  ],
158
177
  }]);
159
178
 
179
+ if (action === 'alt_once' || action === 'alt_save') {
180
+ const { altTicket } = await inquirer.prompt([{
181
+ type: 'input',
182
+ name: 'altTicket',
183
+ message: 'Ticket ID:',
184
+ }]);
185
+ const ticket = altTicket.trim();
186
+ if (!ticket) {
187
+ log.error(' No Ticket ID entered. Submission cancelled.');
188
+ return { useTicket: false, clearTicket: false, cancel: true };
189
+ }
190
+ if (action === 'alt_save') {
191
+ await saveTicketing({ ...data, ticket_id: ticket });
192
+ log.dim(` Stored ticket updated to "${ticket}"`);
193
+ }
194
+ return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };
195
+ }
196
+
160
197
  return {
161
198
  useTicket: action === 'use',
162
199
  clearTicket: action === 'clear',
@@ -173,13 +210,14 @@ export async function checkStoredTicket(options) {
173
210
  * @param {string|number} rowId - Row ID or UID used in the submission
174
211
  * @param {string} uid - Record UID for per-record lookup
175
212
  * @param {Object} options - Command options
213
+ * @param {string|null} [sessionOverride] - One-time ticket override from pre-flight prompt
176
214
  */
177
- export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, uid, options) {
215
+ export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, uid, options, sessionOverride = null) {
178
216
  if (options.ticket) return; // --ticket flag takes precedence
179
217
 
180
218
  const recordTicket = await getRecordTicket(uid);
181
219
  const globalTicket = await getGlobalTicket();
182
- const ticketToUse = recordTicket || globalTicket;
220
+ const ticketToUse = sessionOverride || recordTicket || globalTicket;
183
221
 
184
222
  if (ticketToUse) {
185
223
  const ticketExpr = buildTicketExpression(entity, rowId, ticketToUse);