@dboio/cli 0.6.10 → 0.6.11

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
@@ -1165,15 +1165,23 @@ The submission is then retried with `_OverrideTicketID`. To skip the prompt, pas
1165
1165
 
1166
1166
  #### Pre-submission ticket prompt
1167
1167
 
1168
- When a stored ticket exists in `.dbo/ticketing.local.json`, the CLI prompts before batch submissions:
1168
+ When a stored ticket exists in `.dbo/ticketing.local.json`, the CLI prompts before batch submissions (`push`, `input`, `add`, `content deploy`, `deploy`):
1169
1169
 
1170
1170
  ```
1171
1171
  ? Use stored Ticket ID "TICKET-123" for this submission?
1172
1172
  ❯ Yes, use "TICKET-123"
1173
+ Use a different ticket for this submission only
1174
+ Use a different ticket for this and future submissions
1173
1175
  No, clear stored ticket
1174
1176
  Cancel submission
1175
1177
  ```
1176
1178
 
1179
+ - **Yes** — applies the stored ticket to all records in this submission
1180
+ - **Different ticket (this submission only)** — prompts for a ticket ID, uses it for all records in this invocation without updating `ticketing.local.json`
1181
+ - **Different ticket (this and future)** — prompts for a ticket ID, updates `ticketing.local.json`, and uses it for this and all future submissions
1182
+ - **No, clear** — removes the stored `ticket_id` and proceeds without a ticket
1183
+ - **Cancel** — aborts the submission
1184
+
1177
1185
  #### `.dbo/ticketing.local.json`
1178
1186
 
1179
1187
  Stores ticket IDs for automatic application during submissions:
@@ -1327,6 +1335,11 @@ dbo deploy css:colors --confirm false
1327
1335
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1328
1336
  | `--ticket <id>` | Override ticket ID |
1329
1337
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1338
+ | `--json` | Output raw JSON |
1339
+ | `-v, --verbose` | Show HTTP request details |
1340
+ | `--domain <host>` | Override domain |
1341
+
1342
+ 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
1343
 
1331
1344
  #### `dbo.deploy.json` manifest
1332
1345
 
@@ -1362,6 +1375,33 @@ Create a `dbo.deploy.json` file in your project root to define named deployments
1362
1375
 
1363
1376
  This replaces the curl commands typically embedded in `package.json` scripts.
1364
1377
 
1378
+ #### Non-interactive mode (npm scripts)
1379
+
1380
+ 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:
1381
+
1382
+ - **Stored ticket** — auto-applied from `.dbo/ticketing.local.json`
1383
+ - **ModifyKey** — auto-applied from `.dbo/config.json` (`AppModifyKey`)
1384
+ - **User identity** — auto-applied from `.dbo/credentials.json`
1385
+
1386
+ 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.
1387
+
1388
+ Example `package.json` scripts:
1389
+
1390
+ ```json
1391
+ {
1392
+ "scripts": {
1393
+ "deploy:css": "dbo deploy css:colors && dbo deploy css:layout",
1394
+ "deploy:docs": "dbo deploy doc:readme && dbo deploy doc:api",
1395
+ "deploy:all": "dbo deploy --all"
1396
+ }
1397
+ }
1398
+ ```
1399
+
1400
+ To set up for non-interactive use:
1401
+ 1. Run `dbo login` to store user credentials
1402
+ 2. Run `dbo clone` to store the ModifyKey (if applicable)
1403
+ 3. Set a ticket interactively once — it persists in `ticketing.local.json` for future npm script runs
1404
+
1365
1405
  ---
1366
1406
 
1367
1407
  ## 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.11",
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) {
@@ -44,11 +45,38 @@ const deployCmd = new Command('deploy')
44
45
  return;
45
46
  }
46
47
 
48
+ // Pre-flight ticket validation (only if no --ticket flag)
49
+ let sessionTicketOverride = null;
50
+ if (!options.ticket) {
51
+ const ticketCheck = await checkStoredTicket(options);
52
+ if (ticketCheck.cancel) {
53
+ log.info('Submission cancelled');
54
+ return;
55
+ }
56
+ if (ticketCheck.clearTicket) {
57
+ await clearGlobalTicket();
58
+ log.dim(' Cleared stored ticket');
59
+ }
60
+ if (ticketCheck.overrideTicket) {
61
+ sessionTicketOverride = ticketCheck.overrideTicket;
62
+ }
63
+ }
64
+
47
65
  const extraParams = { '_confirm': options.confirm };
48
66
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
49
67
  if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
50
68
 
51
69
  const dataExprs = [`RowUID:${uid};column:content.Content@${filepath}`];
70
+
71
+ // Apply stored ticket if no --ticket flag
72
+ if (!options.ticket) {
73
+ const storedTicket = sessionTicketOverride || await getRecordTicket(uid) || await getGlobalTicket();
74
+ if (storedTicket) {
75
+ extraParams['_OverrideTicketID'] = storedTicket;
76
+ }
77
+ }
78
+ await applyStoredTicketToSubmission(dataExprs, 'content', uid, uid, options, sessionTicketOverride);
79
+
52
80
  let body = await buildInputBody(dataExprs, extraParams);
53
81
  let result = await client.postUrlEncoded('/api/input/submit', body);
54
82
 
@@ -61,6 +89,22 @@ const deployCmd = new Command('deploy')
61
89
  result = await client.postUrlEncoded('/api/input/submit', body);
62
90
  }
63
91
 
92
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
93
+ const errorResult = await checkSubmitErrors(result);
94
+ if (errorResult) {
95
+ if (errorResult.skipRecord || errorResult.skipAll) {
96
+ log.info('Submission cancelled');
97
+ return;
98
+ }
99
+ if (errorResult.ticketExpressions?.length > 0) {
100
+ dataExprs.push(...errorResult.ticketExpressions);
101
+ }
102
+ const params = errorResult.retryParams || errorResult;
103
+ Object.assign(extraParams, params);
104
+ body = await buildInputBody(dataExprs, extraParams);
105
+ result = await client.postUrlEncoded('/api/input/submit', body);
106
+ }
107
+
64
108
  formatResponse(result, { json: options.json });
65
109
  if (!result.successful) process.exit(1);
66
110
  } catch (err) {
@@ -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,6 +62,23 @@ 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}`);
@@ -84,6 +102,16 @@ export const deployCommand = new Command('deploy')
84
102
  if (activeModifyKey) extraParams['_modify_key'] = activeModifyKey;
85
103
 
86
104
  const dataExprs = [`RowUID:${uid};column:${entity}.${column}@${file}`];
105
+
106
+ // Apply stored ticket if no --ticket flag
107
+ if (!options.ticket) {
108
+ const storedTicket = sessionTicketOverride || await getRecordTicket(uid) || await getGlobalTicket();
109
+ if (storedTicket) {
110
+ extraParams['_OverrideTicketID'] = storedTicket;
111
+ }
112
+ }
113
+ await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options, sessionTicketOverride);
114
+
87
115
  let body = await buildInputBody(dataExprs, extraParams);
88
116
  let result = await client.postUrlEncoded('/api/input/submit', body);
89
117
 
@@ -97,11 +125,31 @@ export const deployCommand = new Command('deploy')
97
125
  result = await client.postUrlEncoded('/api/input/submit', body);
98
126
  }
99
127
 
128
+ // Retry with prompted params if needed (ticket, user, repo mismatch)
129
+ const errorResult = await checkSubmitErrors(result);
130
+ if (errorResult) {
131
+ if (errorResult.skipRecord) {
132
+ log.warn(` Skipping "${entryName}"`);
133
+ continue;
134
+ }
135
+ if (errorResult.skipAll) {
136
+ log.warn(` Skipping "${entryName}" and all remaining`);
137
+ break;
138
+ }
139
+ if (errorResult.ticketExpressions?.length > 0) {
140
+ dataExprs.push(...errorResult.ticketExpressions);
141
+ }
142
+ const params = errorResult.retryParams || errorResult;
143
+ Object.assign(extraParams, params);
144
+ body = await buildInputBody(dataExprs, extraParams);
145
+ result = await client.postUrlEncoded('/api/input/submit', body);
146
+ }
147
+
100
148
  if (result.successful) {
101
149
  log.success(`${entryName} deployed`);
102
150
  } else {
103
151
  log.error(`${entryName} failed`);
104
- for (const msg of result.messages) log.label('Message', msg);
152
+ formatResponse(result, { json: options.json });
105
153
  if (!options.all) process.exit(1);
106
154
  }
107
155
  }
@@ -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);