@dboio/cli 0.6.8 → 0.6.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
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('--modifyKey <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('--modifyKey <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('--modifyKey <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('--modifyKey <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('--modifyKey <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
+ // --modifyKey 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
+ }