@dboio/cli 0.6.8 → 0.6.10

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
@@ -156,6 +156,7 @@ All configuration is **directory-scoped**. Each project folder maintains its own
156
156
  | `AppUID` | string | App UID |
157
157
  | `AppName` | string | App display name |
158
158
  | `AppShortName` | string | App short name (used for `dbo clone --app`) |
159
+ | `AppModifyKey` | string | ModifyKey for locked/production apps (set by `dbo clone`, used for submission guards) |
159
160
  | `ContentPlacement` | `bin` \| `path` \| `ask` | Where to place content files during clone |
160
161
  | `MediaPlacement` | `bin` \| `fullpath` \| `ask` | Where to place media files during clone |
161
162
  | `<Entity>FilenameCol` | column name | Filename column for entity-dir records (e.g., `ExtensionFilenameCol`) |
@@ -239,7 +240,7 @@ dbo init --domain my-domain.com --app myapp --clone
239
240
  #### What clone does
240
241
 
241
242
  1. **Loads app JSON** — from a local file, server API, or interactive prompt
242
- 2. **Updates `.dbo/config.json`** — saves `AppID`, `AppUID`, `AppName`, `AppShortName`
243
+ 2. **Updates `.dbo/config.json`** — saves `AppID`, `AppUID`, `AppName`, `AppShortName`, and `AppModifyKey` (if the app is locked)
243
244
  3. **Updates `package.json`** — populates `name`, `productName`, `description`, `homepage`, and `deploy` script
244
245
  4. **Creates directories** — processes `children.bin` to build the directory hierarchy based on `ParentBinID` relationships
245
246
  5. **Saves `.dbo/structure.json`** — maps BinIDs to directory paths for file placement
@@ -487,6 +488,7 @@ dbo input -d 'RowUID:abc;column:content.Content@file.css' -v
487
488
  | `-f, --file <field=@path>` | File attachment for multipart upload (repeatable) |
488
489
  | `-C, --confirm <true\|false>` | Commit changes (default: `true`). Use `false` for validation only |
489
490
  | `--ticket <id>` | Override ticket ID (`_OverrideTicketID`) |
491
+ | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
490
492
  | `--login` | Auto-login user created by this submission |
491
493
  | `--transactional` | Use transactional processing |
492
494
  | `--json` | Output raw JSON response |
@@ -629,6 +631,7 @@ dbo input -d 'RowUID:albain3dwkofbhnd1qtd1q;column:content.Content@assets/css/co
629
631
  | `<filepath>` | Local file path |
630
632
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
631
633
  | `--ticket <id>` | Override ticket ID |
634
+ | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
632
635
 
633
636
  #### `dbo content pull`
634
637
 
@@ -939,6 +942,7 @@ The `@colors.css` reference tells push to read the content from that file. All o
939
942
  | `<path>` | File or directory to push |
940
943
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
941
944
  | `--ticket <id>` | Override ticket ID |
945
+ | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
942
946
  | `--meta-only` | Only push metadata, skip file content |
943
947
  | `--content-only` | Only push file content, skip metadata |
944
948
  | `-y, --yes` | Auto-accept all prompts |
@@ -1101,6 +1105,7 @@ The `@colors.css` reference tells the CLI to read the file content from `colors.
1101
1105
  | `<path>` | File or `.` to scan current directory |
1102
1106
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1103
1107
  | `--ticket <id>` | Override ticket ID |
1108
+ | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1104
1109
  | `-y, --yes` | Auto-accept all prompts |
1105
1110
  | `--json` | Output raw JSON |
1106
1111
  | `--jq <expr>` | Filter JSON response |
@@ -1192,6 +1197,36 @@ Stores ticket IDs for automatic application during submissions:
1192
1197
  - `records` — Per-record tickets (auto-cleared after successful submission)
1193
1198
  - `--ticket` flag always takes precedence over stored tickets
1194
1199
 
1200
+ #### ModifyKey protection (locked/production apps)
1201
+
1202
+ When an app has a `ModifyKey` set (production/locked mode), the CLI guards all submission commands (`push`, `input`, `add`, `content deploy`, `deploy`) with an interactive prompt:
1203
+
1204
+ ```
1205
+ ⚠ This app is locked (production mode). A ModifyKey is required to submit changes.
1206
+ ? This app has a ModifyKey set. How would you like to proceed?
1207
+ ❯ Enter the ModifyKey to proceed
1208
+ Cancel submission
1209
+ ```
1210
+
1211
+ The ModifyKey is detected and stored during `dbo clone`. If the key wasn't stored locally but the server requires one, the CLI reactively prompts after the first failed submission:
1212
+
1213
+ ```
1214
+ ⚠ This app requires a ModifyKey. The key has not been set locally (try re-running `dbo clone`).
1215
+ ? A ModifyKey is required. How would you like to proceed?
1216
+ ❯ Enter the ModifyKey to retry
1217
+ Cancel submission
1218
+ ```
1219
+
1220
+ On successful reactive entry, the key is saved to `.dbo/config.json` for future use.
1221
+
1222
+ To bypass the interactive prompt entirely, pass `--modify-key <key>` on any submission command:
1223
+
1224
+ ```bash
1225
+ dbo push . --modify-key mySecretKey
1226
+ dbo input -d '...' --modify-key mySecretKey
1227
+ dbo add myfile.css --modify-key mySecretKey
1228
+ ```
1229
+
1195
1230
  #### User identity required
1196
1231
 
1197
1232
  When the server returns an error mentioning `LoggedInUser_UID`, `LoggedInUserID`, `CurrentUserID`, or `UserID`, the CLI checks for a stored user identity from `dbo login`:
@@ -1291,6 +1326,7 @@ dbo deploy css:colors --confirm false
1291
1326
  | `--all` | Deploy all entries in the manifest |
1292
1327
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1293
1328
  | `--ticket <id>` | Override ticket ID |
1329
+ | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1294
1330
 
1295
1331
  #### `dbo.deploy.json` manifest
1296
1332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@ import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
9
  import { loadAppConfig } from '../lib/config.js';
10
10
  import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
11
12
 
12
13
  // Directories and patterns to skip when scanning with `dbo add .`
13
14
  const IGNORE_DIRS = new Set(['.dbo', '.git', 'node_modules', '.svn', '.hg']);
@@ -17,6 +18,7 @@ export const addCommand = new Command('add')
17
18
  .argument('<path>', 'File or "." to scan current directory')
18
19
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
19
20
  .option('--ticket <id>', 'Override ticket ID')
21
+ .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
20
22
  .option('-y, --yes', 'Auto-accept all prompts')
21
23
  .option('--json', 'Output raw JSON')
22
24
  .option('--jq <expr>', 'Filter JSON response')
@@ -26,6 +28,14 @@ export const addCommand = new Command('add')
26
28
  try {
27
29
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
28
30
 
31
+ // ModifyKey guard
32
+ const modifyKeyResult = await checkModifyKey(options);
33
+ if (modifyKeyResult.cancel) {
34
+ log.info('Submission cancelled');
35
+ return;
36
+ }
37
+ if (modifyKeyResult.modifyKey) options._resolvedModifyKey = modifyKeyResult.modifyKey;
38
+
29
39
  if (targetPath === '.') {
30
40
  await addDirectory(process.cwd(), client, options);
31
41
  } else {
@@ -242,10 +252,21 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
242
252
 
243
253
  const extraParams = { '_confirm': options.confirm };
244
254
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
255
+ if (options._resolvedModifyKey) extraParams['_modify_key'] = options._resolvedModifyKey;
245
256
 
246
257
  let body = await buildInputBody(dataExprs, extraParams);
247
258
  let result = await client.postUrlEncoded('/api/input/submit', body);
248
259
 
260
+ // Reactive ModifyKey retry
261
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
262
+ const retryMK = await handleModifyKeyError();
263
+ if (retryMK.cancel) { log.info('Submission cancelled'); return null; }
264
+ extraParams['_modify_key'] = retryMK.modifyKey;
265
+ options._resolvedModifyKey = retryMK.modifyKey;
266
+ body = await buildInputBody(dataExprs, extraParams);
267
+ result = await client.postUrlEncoded('/api/input/submit', body);
268
+ }
269
+
249
270
  // Retry with prompted params if needed (ticket, user, repo mismatch)
250
271
  const retryResult = await checkSubmitErrors(result);
251
272
  if (retryResult) {
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { readFile, writeFile, mkdir, access } from 'fs/promises';
3
3
  import { join, basename, extname } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize } from '../lib/config.js';
5
+ import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey } from '../lib/config.js';
6
6
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { setFileTimestamps } from '../lib/timestamps.js';
@@ -501,6 +501,16 @@ export async function performClone(source, options = {}) {
501
501
  });
502
502
  log.dim(' Updated .dbo/config.json with app metadata');
503
503
 
504
+ // Detect and store ModifyKey for locked/production apps
505
+ const modifyKey = appJson.ModifyKey || null;
506
+ await saveAppModifyKey(modifyKey);
507
+ if (modifyKey) {
508
+ log.warn('');
509
+ log.warn(' ⚠ This app has a ModifyKey set (production/locked mode).');
510
+ log.warn(' You will be prompted to enter the ModifyKey before any push, input, add, content deploy, or deploy command.');
511
+ log.warn('');
512
+ }
513
+
504
514
  // Step 3: Update package.json
505
515
  await updatePackageJson(appJson, config);
506
516
 
@@ -5,6 +5,7 @@ import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { saveToDisk } from '../lib/save-to-disk.js';
8
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
8
9
  import { log } from '../lib/logger.js';
9
10
 
10
11
  function collect(value, previous) {
@@ -28,16 +29,38 @@ const deployCmd = new Command('deploy')
28
29
  .argument('<filepath>', 'Local file path')
29
30
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
30
31
  .option('--ticket <id>', 'Override ticket ID')
32
+ .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
31
33
  .option('--json', 'Output raw JSON')
32
34
  .option('-v, --verbose', 'Show HTTP request details')
33
35
  .option('--domain <host>', 'Override domain')
34
36
  .action(async (uid, filepath, options) => {
35
37
  try {
36
38
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
39
+
40
+ // ModifyKey guard
41
+ const modifyKeyResult = await checkModifyKey(options);
42
+ if (modifyKeyResult.cancel) {
43
+ log.info('Submission cancelled');
44
+ return;
45
+ }
46
+
37
47
  const extraParams = { '_confirm': options.confirm };
38
48
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
39
- const body = await buildInputBody([`RowUID:${uid};column:content.Content@${filepath}`], extraParams);
40
- const result = await client.postUrlEncoded('/api/input/submit', body);
49
+ if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
50
+
51
+ const dataExprs = [`RowUID:${uid};column:content.Content@${filepath}`];
52
+ let body = await buildInputBody(dataExprs, extraParams);
53
+ let result = await client.postUrlEncoded('/api/input/submit', body);
54
+
55
+ // Reactive ModifyKey retry
56
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
57
+ const retryMK = await handleModifyKeyError();
58
+ if (retryMK.cancel) { log.info('Submission cancelled'); return; }
59
+ extraParams['_modify_key'] = retryMK.modifyKey;
60
+ body = await buildInputBody(dataExprs, extraParams);
61
+ result = await client.postUrlEncoded('/api/input/submit', body);
62
+ }
63
+
41
64
  formatResponse(result, { json: options.json });
42
65
  if (!result.successful) process.exit(1);
43
66
  } catch (err) {
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises';
3
3
  import { DboClient } from '../lib/client.js';
4
4
  import { buildInputBody } from '../lib/input-parser.js';
5
5
  import { formatResponse, formatError } from '../lib/formatter.js';
6
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
6
7
  import { log } from '../lib/logger.js';
7
8
 
8
9
  const MANIFEST_FILE = 'dbo.deploy.json';
@@ -13,6 +14,7 @@ export const deployCommand = new Command('deploy')
13
14
  .option('--all', 'Deploy all entries in the manifest')
14
15
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
15
16
  .option('--ticket <id>', 'Override ticket ID')
17
+ .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
16
18
  .option('--json', 'Output raw JSON')
17
19
  .option('-v, --verbose', 'Show HTTP request details')
18
20
  .option('--domain <host>', 'Override domain')
@@ -51,6 +53,14 @@ export const deployCommand = new Command('deploy')
51
53
  process.exit(1);
52
54
  }
53
55
 
56
+ // ModifyKey guard — check once before any submissions
57
+ const modifyKeyResult = await checkModifyKey(options);
58
+ if (modifyKeyResult.cancel) {
59
+ log.info('Submission cancelled');
60
+ return;
61
+ }
62
+ let activeModifyKey = modifyKeyResult.modifyKey;
63
+
54
64
  for (const [entryName, entry] of entries) {
55
65
  if (!entry) {
56
66
  log.warn(`Skipping unknown deployment: ${entryName}`);
@@ -71,8 +81,21 @@ export const deployCommand = new Command('deploy')
71
81
 
72
82
  const extraParams = { '_confirm': options.confirm };
73
83
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
74
- const body = await buildInputBody([`RowUID:${uid};column:${entity}.${column}@${file}`], extraParams);
75
- const result = await client.postUrlEncoded('/api/input/submit', body);
84
+ if (activeModifyKey) extraParams['_modify_key'] = activeModifyKey;
85
+
86
+ const dataExprs = [`RowUID:${uid};column:${entity}.${column}@${file}`];
87
+ let body = await buildInputBody(dataExprs, extraParams);
88
+ let result = await client.postUrlEncoded('/api/input/submit', body);
89
+
90
+ // Reactive ModifyKey retry
91
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
92
+ const retryMK = await handleModifyKeyError();
93
+ if (retryMK.cancel) { log.info('Submission cancelled'); break; }
94
+ activeModifyKey = retryMK.modifyKey;
95
+ extraParams['_modify_key'] = activeModifyKey;
96
+ body = await buildInputBody(dataExprs, extraParams);
97
+ result = await client.postUrlEncoded('/api/input/submit', body);
98
+ }
76
99
 
77
100
  if (result.successful) {
78
101
  log.success(`${entryName} deployed`);
@@ -4,6 +4,7 @@ import { buildInputBody, parseFileArg, checkSubmitErrors } from '../lib/input-pa
4
4
  import { formatResponse, formatError } from '../lib/formatter.js';
5
5
  import { loadAppConfig } from '../lib/config.js';
6
6
  import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
7
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
7
8
  import { log } from '../lib/logger.js';
8
9
 
9
10
  function collect(value, previous) {
@@ -16,6 +17,7 @@ export const inputCommand = new Command('input')
16
17
  .option('-f, --file <field=@path>', 'File attachment for multipart upload (repeatable)', collect, [])
17
18
  .option('-C, --confirm <value>', 'Commit changes: true (default) or false for validation only', 'true')
18
19
  .option('--ticket <id>', 'Override ticket ID (_OverrideTicketID)')
20
+ .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
19
21
  .option('--login', 'Auto-login user created by this submission')
20
22
  .option('--transactional', 'Use transactional processing')
21
23
  .option('--json', 'Output raw JSON response')
@@ -45,6 +47,14 @@ export const inputCommand = new Command('input')
45
47
  }
46
48
  }
47
49
 
50
+ // ModifyKey guard
51
+ const modifyKeyResult = await checkModifyKey(options);
52
+ if (modifyKeyResult.cancel) {
53
+ log.info('Submission cancelled');
54
+ return;
55
+ }
56
+ if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
57
+
48
58
  // Check if data expressions include AppID; if not and config has one, prompt
49
59
  const allDataText = options.data.join(' ');
50
60
  const hasAppId = /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
@@ -93,6 +103,14 @@ export const inputCommand = new Command('input')
93
103
  const files = options.file.map(parseFileArg);
94
104
  let result = await client.postMultipart('/api/input/submit', fields, files);
95
105
 
106
+ // Reactive ModifyKey retry
107
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
108
+ const retryMK = await handleModifyKeyError();
109
+ if (retryMK.cancel) { log.info('Submission cancelled'); return; }
110
+ fields['_modify_key'] = retryMK.modifyKey;
111
+ result = await client.postMultipart('/api/input/submit', fields, files);
112
+ }
113
+
96
114
  // Retry with prompted params if needed (ticket, user, repo mismatch)
97
115
  const retryResult = await checkSubmitErrors(result);
98
116
  if (retryResult) {
@@ -112,6 +130,15 @@ export const inputCommand = new Command('input')
112
130
  let body = await buildInputBody(options.data, extraParams);
113
131
  let result = await client.postUrlEncoded('/api/input/submit', body);
114
132
 
133
+ // Reactive ModifyKey retry
134
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
135
+ const retryMK = await handleModifyKeyError();
136
+ if (retryMK.cancel) { log.info('Submission cancelled'); return; }
137
+ extraParams['_modify_key'] = retryMK.modifyKey;
138
+ body = await buildInputBody(options.data, extraParams);
139
+ result = await client.postUrlEncoded('/api/input/submit', body);
140
+ }
141
+
115
142
  // Retry with prompted params if needed (ticket, user, repo mismatch)
116
143
  const retryResult = await checkSubmitErrors(result);
117
144
  if (retryResult) {
@@ -8,6 +8,7 @@ import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
9
  import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
10
10
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
+ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
11
12
  import { setFileTimestamps } from '../lib/timestamps.js';
12
13
  import { findMetadataFiles } from '../lib/diff.js';
13
14
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
@@ -18,6 +19,7 @@ export const pushCommand = new Command('push')
18
19
  .argument('<path>', 'File or directory to push')
19
20
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
20
21
  .option('--ticket <id>', 'Override ticket ID')
22
+ .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
21
23
  .option('--meta-only', 'Only push metadata changes, skip file content')
22
24
  .option('--content-only', 'Only push file content, skip metadata columns')
23
25
  .option('-y, --yes', 'Auto-accept all prompts (path refactoring, etc.)')
@@ -29,15 +31,23 @@ export const pushCommand = new Command('push')
29
31
  try {
30
32
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
31
33
 
34
+ // ModifyKey guard — check once before any submissions
35
+ const modifyKeyResult = await checkModifyKey(options);
36
+ if (modifyKeyResult.cancel) {
37
+ log.info('Submission cancelled');
38
+ return;
39
+ }
40
+ const modifyKey = modifyKeyResult.modifyKey;
41
+
32
42
  // Process pending deletions from synchronize.json
33
- await processPendingDeletes(client, options);
43
+ await processPendingDeletes(client, options, modifyKey);
34
44
 
35
45
  const pathStat = await stat(targetPath);
36
46
 
37
47
  if (pathStat.isDirectory()) {
38
- await pushDirectory(targetPath, client, options);
48
+ await pushDirectory(targetPath, client, options, modifyKey);
39
49
  } else {
40
- await pushSingleFile(targetPath, client, options);
50
+ await pushSingleFile(targetPath, client, options, modifyKey);
41
51
  }
42
52
  } catch (err) {
43
53
  formatError(err);
@@ -48,7 +58,7 @@ export const pushCommand = new Command('push')
48
58
  /**
49
59
  * Process pending delete entries from .dbo/synchronize.json
50
60
  */
51
- async function processPendingDeletes(client, options) {
61
+ async function processPendingDeletes(client, options, modifyKey = null) {
52
62
  const sync = await loadSynchronize();
53
63
  if (!sync.delete || sync.delete.length === 0) return;
54
64
 
@@ -62,6 +72,7 @@ async function processPendingDeletes(client, options) {
62
72
 
63
73
  const extraParams = { '_confirm': options.confirm || 'true' };
64
74
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
75
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
65
76
 
66
77
  const body = await buildInputBody([entry.expression], extraParams);
67
78
 
@@ -69,23 +80,33 @@ async function processPendingDeletes(client, options) {
69
80
  const result = await client.postUrlEncoded('/api/input/submit', body);
70
81
 
71
82
  // Retry with prompted params if needed
72
- const retryResult = await checkSubmitErrors(result);
73
- if (retryResult) {
74
- if (retryResult.skipRecord || retryResult.skipAll) {
83
+ const errorResult = await checkSubmitErrors(result);
84
+ if (errorResult) {
85
+ if (errorResult.skipRecord) {
75
86
  log.warn(` Skipping deletion of "${entry.name}"`);
76
87
  remaining.push(entry);
77
88
  continue;
78
89
  }
79
- const params = retryResult.retryParams || retryResult;
90
+ if (errorResult.skipAll) {
91
+ log.warn(` Skipping deletion of "${entry.name}" and all remaining`);
92
+ remaining.push(entry);
93
+ // Push all remaining entries too
94
+ const currentIdx = sync.delete.indexOf(entry);
95
+ for (let i = currentIdx + 1; i < sync.delete.length; i++) {
96
+ remaining.push(sync.delete[i]);
97
+ }
98
+ break;
99
+ }
100
+ const params = errorResult.retryParams || errorResult;
80
101
  Object.assign(extraParams, params);
81
102
  const retryBody = await buildInputBody([entry.expression], extraParams);
82
- const retryResult = await client.postUrlEncoded('/api/input/submit', retryBody);
83
- if (retryResult.successful) {
103
+ const retryResponse = await client.postUrlEncoded('/api/input/submit', retryBody);
104
+ if (retryResponse.successful) {
84
105
  log.success(` Deleted "${entry.name}" from server`);
85
106
  deletedUids.push(entry.UID);
86
107
  } else {
87
108
  log.error(` Failed to delete "${entry.name}"`);
88
- formatResponse(retryResult, { json: options.json, jq: options.jq });
109
+ formatResponse(retryResponse, { json: options.json, jq: options.jq });
89
110
  remaining.push(entry);
90
111
  }
91
112
  } else if (result.successful) {
@@ -119,7 +140,7 @@ async function processPendingDeletes(client, options) {
119
140
  /**
120
141
  * Push a single file using its companion .metadata.json
121
142
  */
122
- async function pushSingleFile(filePath, client, options) {
143
+ async function pushSingleFile(filePath, client, options, modifyKey = null) {
123
144
  // Find the metadata file
124
145
  const dir = dirname(filePath);
125
146
  const base = basename(filePath, extname(filePath));
@@ -133,13 +154,13 @@ async function pushSingleFile(filePath, client, options) {
133
154
  process.exit(1);
134
155
  }
135
156
 
136
- await pushFromMetadata(meta, metaPath, client, options);
157
+ await pushFromMetadata(meta, metaPath, client, options, null, modifyKey);
137
158
  }
138
159
 
139
160
  /**
140
161
  * Push all records found in a directory (recursive)
141
162
  */
142
- async function pushDirectory(dirPath, client, options) {
163
+ async function pushDirectory(dirPath, client, options, modifyKey = null) {
143
164
  const metaFiles = await findMetadataFiles(dirPath);
144
165
 
145
166
  if (metaFiles.length === 0) {
@@ -251,7 +272,7 @@ async function pushDirectory(dirPath, client, options) {
251
272
 
252
273
  for (const item of toPush) {
253
274
  try {
254
- const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns);
275
+ const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey);
255
276
  if (success) {
256
277
  succeeded++;
257
278
  successfulPushes.push(item);
@@ -285,7 +306,7 @@ async function pushDirectory(dirPath, client, options) {
285
306
  * @param {string[]|null} changedColumns - Optional array of changed column names (for delta sync)
286
307
  * @returns {Promise<boolean>} - True if push succeeded
287
308
  */
288
- async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null) {
309
+ async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null, modifyKey = null) {
289
310
  const uid = meta.UID || meta._id;
290
311
  const entity = meta._entity;
291
312
  const contentCols = new Set(meta._contentColumns || []);
@@ -349,10 +370,20 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
349
370
 
350
371
  const extraParams = { '_confirm': options.confirm };
351
372
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
373
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
352
374
 
353
375
  let body = await buildInputBody(dataExprs, extraParams);
354
376
  let result = await client.postUrlEncoded('/api/input/submit', body);
355
377
 
378
+ // Reactive ModifyKey retry — server rejected because key wasn't set locally
379
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
380
+ const retryMK = await handleModifyKeyError();
381
+ if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
382
+ extraParams['_modify_key'] = retryMK.modifyKey;
383
+ body = await buildInputBody(dataExprs, extraParams);
384
+ result = await client.postUrlEncoded('/api/input/submit', body);
385
+ }
386
+
356
387
  // Retry with prompted params if needed (ticket, user, repo mismatch)
357
388
  const retryResult = await checkSubmitErrors(result);
358
389
  if (retryResult) {
package/src/lib/config.js CHANGED
@@ -181,9 +181,10 @@ export async function loadAppConfig() {
181
181
  AppUID: config.AppUID || null,
182
182
  AppName: config.AppName || null,
183
183
  AppShortName: config.AppShortName || null,
184
+ AppModifyKey: config.AppModifyKey || null,
184
185
  };
185
186
  } catch {
186
- return { AppID: null, AppUID: null, AppName: null, AppShortName: null };
187
+ return { AppID: null, AppUID: null, AppName: null, AppShortName: null, AppModifyKey: null };
187
188
  }
188
189
  }
189
190
 
@@ -528,6 +529,32 @@ export async function getAllPluginScopes() {
528
529
  return result;
529
530
  }
530
531
 
532
+ // ─── AppModifyKey ─────────────────────────────────────────────────────────
533
+
534
+ /**
535
+ * Save AppModifyKey to .dbo/config.json.
536
+ * Pass null to remove the key (e.g. when server no longer has one).
537
+ */
538
+ export async function saveAppModifyKey(modifyKey) {
539
+ await mkdir(dboDir(), { recursive: true });
540
+ let existing = {};
541
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
542
+ if (modifyKey != null) existing.AppModifyKey = modifyKey;
543
+ else delete existing.AppModifyKey;
544
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
545
+ }
546
+
547
+ /**
548
+ * Load AppModifyKey from .dbo/config.json.
549
+ * Returns the key string or null if not set.
550
+ */
551
+ export async function loadAppModifyKey() {
552
+ try {
553
+ const raw = await readFile(configPath(), 'utf8');
554
+ return JSON.parse(raw).AppModifyKey || null;
555
+ } catch { return null; }
556
+ }
557
+
531
558
  // ─── Gitignore ────────────────────────────────────────────────────────────
532
559
 
533
560
  /**
@@ -116,7 +116,8 @@ export async function checkSubmitErrors(result) {
116
116
  const allText = messages.filter(m => typeof m === 'string').join(' ');
117
117
 
118
118
  // --- Ticket error detection (new interactive handling) ---
119
- const hasTicketError = allText.includes('ticket_error');
119
+ // Match ticket_error, ticket_lookup_error, and similar variants
120
+ const hasTicketError = allText.includes('ticket_error') || allText.includes('ticket_lookup_error');
120
121
  const hasRepoMismatch = allText.includes('repo_mismatch');
121
122
 
122
123
  if (hasTicketError) {
@@ -0,0 +1,96 @@
1
+ import { loadAppModifyKey, saveAppModifyKey } from './config.js';
2
+ import { log } from './logger.js';
3
+
4
+ /**
5
+ * Pre-submission ModifyKey guard.
6
+ * - If options.modifyKey flag is set, it takes precedence — no prompt, no config check.
7
+ * - Returns { modifyKey: null } if no key is set in config (no-op).
8
+ * - Prompts user to type the key; returns { modifyKey: string } on match.
9
+ * - Returns { modifyKey: null, cancel: true } on mismatch or cancel.
10
+ */
11
+ export async function checkModifyKey(options = {}) {
12
+ // --modify-key flag takes precedence (mirrors --ticket / --confirm behavior)
13
+ if (options.modifyKey) return { modifyKey: options.modifyKey, cancel: false };
14
+
15
+ const storedKey = await loadAppModifyKey();
16
+ if (!storedKey) return { modifyKey: null, cancel: false };
17
+
18
+ log.warn('');
19
+ log.warn(' ⚠ This app is locked (production mode). A ModifyKey is required to submit changes.');
20
+ log.warn('');
21
+
22
+ const inquirer = (await import('inquirer')).default;
23
+ const { action } = await inquirer.prompt([{
24
+ type: 'list',
25
+ name: 'action',
26
+ message: 'This app has a ModifyKey set. How would you like to proceed?',
27
+ choices: [
28
+ { name: 'Enter the ModifyKey to proceed', value: 'enter' },
29
+ { name: 'Cancel submission', value: 'cancel' },
30
+ ],
31
+ }]);
32
+
33
+ if (action === 'cancel') return { modifyKey: null, cancel: true };
34
+
35
+ const { enteredKey } = await inquirer.prompt([{
36
+ type: 'input',
37
+ name: 'enteredKey',
38
+ message: 'ModifyKey:',
39
+ }]);
40
+
41
+ const key = enteredKey.trim();
42
+ if (!key) {
43
+ log.error(' No ModifyKey entered. Submission cancelled.');
44
+ return { modifyKey: null, cancel: true };
45
+ }
46
+
47
+ return { modifyKey: key, cancel: false };
48
+ }
49
+
50
+ /**
51
+ * Detects whether a server response error message requires a ModifyKey.
52
+ * Server error format: "Error:The '<app>' app (UID=...) has a ModifyKey – ..."
53
+ */
54
+ export function isModifyKeyError(responseMessage) {
55
+ return typeof responseMessage === 'string' && responseMessage.includes('has a ModifyKey');
56
+ }
57
+
58
+ /**
59
+ * Reactive ModifyKey handler — called after a submission fails with a ModifyKey error.
60
+ * Prompts the user to enter the key, saves it to config, and returns it for retry.
61
+ * Returns { modifyKey: string } on success or { modifyKey: null, cancel: true } on cancel.
62
+ */
63
+ export async function handleModifyKeyError() {
64
+ log.warn('');
65
+ log.warn(' ⚠ This app requires a ModifyKey. The key has not been set locally (try re-running `dbo clone`).');
66
+ log.warn('');
67
+
68
+ const inquirer = (await import('inquirer')).default;
69
+ const { action } = await inquirer.prompt([{
70
+ type: 'list',
71
+ name: 'action',
72
+ message: 'A ModifyKey is required. How would you like to proceed?',
73
+ choices: [
74
+ { name: 'Enter the ModifyKey to retry', value: 'enter' },
75
+ { name: 'Cancel submission', value: 'cancel' },
76
+ ],
77
+ }]);
78
+
79
+ if (action === 'cancel') return { modifyKey: null, cancel: true };
80
+
81
+ const { enteredKey } = await inquirer.prompt([{
82
+ type: 'input',
83
+ name: 'enteredKey',
84
+ message: 'ModifyKey:',
85
+ }]);
86
+
87
+ const key = enteredKey.trim();
88
+ if (!key) {
89
+ log.error(' No ModifyKey entered. Submission cancelled.');
90
+ return { modifyKey: null, cancel: true };
91
+ }
92
+
93
+ await saveAppModifyKey(key);
94
+ log.info(' ModifyKey saved to .dbo/config.json for future use.');
95
+ return { modifyKey: key, cancel: false };
96
+ }