@dboio/cli 0.9.6 → 0.10.1

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.
@@ -172,7 +172,7 @@ async function rmFile(filePath, options) {
172
172
  process.exit(1);
173
173
  }
174
174
  if (!rowId) {
175
- log.error(`No row ID found in "${metaPath}". Cannot build delete expression.`);
175
+ log.error(`No row ID found in "${metaPath}". Cannot build delete expression.\n Metadata needs _id or ${entity.charAt(0).toUpperCase() + entity.slice(1)}ID. Run "dbo pull" to populate.`);
176
176
  process.exit(1);
177
177
  }
178
178
 
@@ -0,0 +1,68 @@
1
+ import { Command } from 'commander';
2
+ import { log } from '../lib/logger.js';
3
+ import { loadConfig, loadAppConfig, saveAppJsonBaseline } from '../lib/config.js';
4
+ import { DboClient } from '../lib/client.js';
5
+ import { decodeBase64Fields } from './clone.js';
6
+
7
+ export const syncCommand = new Command('sync')
8
+ .description('Synchronise local state with the server')
9
+ .option('--baseline', 'Re-fetch server state and update .dbo/.app_baseline.json (does not modify local files)')
10
+ .action(async (options) => {
11
+ if (!options.baseline) {
12
+ log.warn('No sync mode specified. Use --baseline to reset the baseline file.');
13
+ process.exit(1);
14
+ }
15
+
16
+ const config = await loadConfig();
17
+ if (!config.domain) {
18
+ log.error('No domain configured. Run "dbo init" first.');
19
+ process.exit(1);
20
+ }
21
+
22
+ const appConfig = await loadAppConfig();
23
+ if (!appConfig.AppShortName) {
24
+ log.error('No AppShortName found. Run "dbo clone" first.');
25
+ process.exit(1);
26
+ }
27
+
28
+ const ora = (await import('ora')).default;
29
+ const spinner = ora('Syncing baseline from server...').start();
30
+
31
+ const client = new DboClient({ domain: config.domain, verbose: options.verbose });
32
+
33
+ let result;
34
+ try {
35
+ result = await client.get(`/api/app/object/${appConfig.AppShortName}`);
36
+ } catch (err) {
37
+ spinner.fail(`Failed to fetch app JSON: ${err.message}`);
38
+ process.exit(1);
39
+ }
40
+
41
+ const data = result.payload || result.data;
42
+
43
+ let appRecord;
44
+ if (Array.isArray(data)) {
45
+ appRecord = data.length > 0 ? data[0] : null;
46
+ } else if (data?.Rows?.length > 0) {
47
+ appRecord = data.Rows[0];
48
+ } else if (data?.rows?.length > 0) {
49
+ appRecord = data.rows[0];
50
+ } else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
51
+ appRecord = data;
52
+ } else {
53
+ appRecord = null;
54
+ }
55
+
56
+ if (!appRecord) {
57
+ spinner.fail(`No app found with ShortName "${appConfig.AppShortName}"`);
58
+ process.exit(1);
59
+ }
60
+
61
+ // Deep clone and decode base64 fields (same logic as clone's saveBaselineFile)
62
+ const baseline = JSON.parse(JSON.stringify(appRecord));
63
+ decodeBase64Fields(baseline);
64
+
65
+ await saveAppJsonBaseline(baseline);
66
+ spinner.succeed('.dbo/.app_baseline.json updated from server');
67
+ log.dim(' Run "dbo push" to sync local changes against the new baseline');
68
+ });
package/src/lib/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir, access } from 'fs/promises';
1
+ import { readFile, writeFile, mkdir, access, chmod, unlink } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { log } from './logger.js';
4
4
 
@@ -8,7 +8,7 @@ const CONFIG_LOCAL_FILE = 'config.local.json';
8
8
  const CREDENTIALS_FILE = 'credentials.json';
9
9
  const COOKIES_FILE = 'cookies.txt';
10
10
  const SYNCHRONIZE_FILE = 'synchronize.json';
11
- const BASELINE_FILE = '.app.json';
11
+ const BASELINE_FILE = '.app_baseline.json';
12
12
 
13
13
  function dboDir() {
14
14
  return join(process.cwd(), DBO_DIR);
@@ -693,10 +693,10 @@ export async function ensureGitignore(patterns) {
693
693
  for (const p of toAdd) log.dim(` Added ${p} to .gitignore`);
694
694
  }
695
695
 
696
- // ─── Baseline (.app.json) ─────────────────────────────────────────────────
696
+ // ─── Baseline (.dbo/.app_baseline.json) ───────────────────────────────────
697
697
 
698
- function baselinePath() {
699
- return join(process.cwd(), BASELINE_FILE);
698
+ export function baselinePath() {
699
+ return join(dboDir(), BASELINE_FILE);
700
700
  }
701
701
 
702
702
  /**
@@ -707,11 +707,38 @@ export async function hasBaseline() {
707
707
  }
708
708
 
709
709
  /**
710
- * Load .app.json baseline file (tracks server state for delta detection).
710
+ * Load .dbo/.app_baseline.json baseline file.
711
+ * Auto-migrates from legacy root .app.json if the new path does not exist.
711
712
  */
712
713
  export async function loadAppJsonBaseline() {
714
+ const newPath = baselinePath();
715
+
716
+ // Legacy migration: root .app.json → .dbo/.app_baseline.json
717
+ if (!(await exists(newPath))) {
718
+ const legacyPath = join(process.cwd(), '.app.json');
719
+ if (await exists(legacyPath)) {
720
+ let parsed;
721
+ try {
722
+ parsed = JSON.parse(await readFile(legacyPath, 'utf8'));
723
+ } catch {
724
+ log.warn('Could not migrate .app.json — file is not valid JSON. Delete it manually and run "dbo clone" to recreate the baseline.');
725
+ return null;
726
+ }
727
+ await mkdir(dboDir(), { recursive: true });
728
+ await writeFile(newPath, JSON.stringify(parsed, null, 2) + '\n');
729
+ try { await chmod(newPath, 0o444); } catch { /* ignore */ }
730
+ try {
731
+ await unlink(legacyPath);
732
+ } catch {
733
+ log.warn('Migrated baseline but could not delete root .app.json — please remove it manually.');
734
+ }
735
+ log.dim('Migrated .app.json → .dbo/.app_baseline.json (system-managed baseline)');
736
+ return parsed;
737
+ }
738
+ }
739
+
713
740
  try {
714
- const raw = await readFile(baselinePath(), 'utf8');
741
+ const raw = await readFile(newPath, 'utf8');
715
742
  return JSON.parse(raw);
716
743
  } catch {
717
744
  return null;
@@ -719,10 +746,24 @@ export async function loadAppJsonBaseline() {
719
746
  }
720
747
 
721
748
  /**
722
- * Save .app.json baseline file.
749
+ * Save .dbo/.app_baseline.json baseline file.
750
+ * Temporarily widens permissions before writing (chmod 0o644),
751
+ * then restores read-only (chmod 0o444) after writing.
723
752
  */
724
753
  export async function saveAppJsonBaseline(data) {
754
+ await mkdir(dboDir(), { recursive: true });
755
+
756
+ try {
757
+ await chmod(baselinePath(), 0o644);
758
+ } catch { /* file doesn't exist yet — first write */ }
759
+
725
760
  await writeFile(baselinePath(), JSON.stringify(data, null, 2) + '\n');
761
+
762
+ try {
763
+ await chmod(baselinePath(), 0o444);
764
+ } catch {
765
+ log.warn('⚠ Could not set baseline file permissions — ensure .dbo/.app_baseline.json is not manually edited');
766
+ }
726
767
  }
727
768
 
728
769
  /**
package/src/lib/delta.js CHANGED
@@ -1,37 +1,26 @@
1
1
  import { readFile, stat } from 'fs/promises';
2
2
  import { join, dirname } from 'path';
3
3
  import { log } from './logger.js';
4
+ import { loadAppJsonBaseline, saveAppJsonBaseline } from './config.js';
4
5
 
5
6
  /**
6
- * Load the baseline file (.app.json) from disk.
7
+ * Load the baseline file from disk.
8
+ * Delegates to loadAppJsonBaseline() in config.js for the canonical path.
7
9
  *
8
- * @param {string} cwd - Current working directory
9
10
  * @returns {Promise<Object|null>} - Baseline JSON or null if not found
10
11
  */
11
- export async function loadBaseline(cwd = process.cwd()) {
12
- const baselinePath = join(cwd, '.app.json');
13
- try {
14
- const raw = await readFile(baselinePath, 'utf8');
15
- return JSON.parse(raw);
16
- } catch (err) {
17
- if (err.code === 'ENOENT') {
18
- return null; // Baseline doesn't exist
19
- }
20
- log.warn(`Failed to parse .app.json: ${err.message}`);
21
- return null;
22
- }
12
+ export async function loadBaseline() {
13
+ return loadAppJsonBaseline();
23
14
  }
24
15
 
25
16
  /**
26
- * Save baseline data to .app.json file.
17
+ * Save baseline data to disk.
18
+ * Delegates to saveAppJsonBaseline() in config.js for the canonical path.
27
19
  *
28
20
  * @param {Object} data - Baseline JSON data
29
- * @param {string} cwd - Current working directory
30
21
  */
31
- export async function saveBaseline(data, cwd = process.cwd()) {
32
- const { writeFile } = await import('fs/promises');
33
- const baselinePath = join(cwd, '.app.json');
34
- await writeFile(baselinePath, JSON.stringify(data, null, 2), 'utf8');
22
+ export async function saveBaseline(data) {
23
+ return saveAppJsonBaseline(data);
35
24
  }
36
25
 
37
26
  /**
@@ -48,12 +37,31 @@ export function findBaselineEntry(baseline, entity, uid) {
48
37
  return null;
49
38
  }
50
39
 
40
+ // Check top-level array first
51
41
  const entityArray = baseline.children[entity];
52
- if (!Array.isArray(entityArray)) {
53
- return null;
42
+ if (Array.isArray(entityArray)) {
43
+ const found = entityArray.find(item => item.UID === uid);
44
+ if (found) return found;
45
+ }
46
+
47
+ // For output hierarchy entities (output_value, output_value_filter,
48
+ // output_value_entity_column_rel), also search nested inside each
49
+ // output record's .children — server nests them per-output.
50
+ if (entity !== 'output') {
51
+ const outputs = baseline.children.output;
52
+ if (Array.isArray(outputs)) {
53
+ for (const o of outputs) {
54
+ if (!o.children) continue;
55
+ const nested = o.children[entity];
56
+ if (Array.isArray(nested)) {
57
+ const found = nested.find(item => item.UID === uid);
58
+ if (found) return found;
59
+ }
60
+ }
61
+ }
54
62
  }
55
63
 
56
- return entityArray.find(item => item.UID === uid) || null;
64
+ return null;
57
65
  }
58
66
 
59
67
  /**
@@ -127,6 +135,13 @@ export async function detectChangedColumns(metaPath, baseline) {
127
135
  const baselineValue = normalizeValue(baselineEntry[columnName]);
128
136
 
129
137
  if (currentValue !== baselineValue) {
138
+ // Skip Extension column when baseline is null/empty — clone derives
139
+ // it from the filename for local use but it's not a user change.
140
+ if (columnName === 'Extension'
141
+ && !baselineValue
142
+ && (baselineEntry[columnName] === null || baselineEntry[columnName] === undefined)) {
143
+ continue;
144
+ }
130
145
  changedColumns.push(columnName);
131
146
  }
132
147
  }
@@ -154,7 +169,7 @@ export async function detectChangedColumns(metaPath, baseline) {
154
169
  * @param {Object} metadata - Metadata object
155
170
  * @returns {string[]} - Array of user column names
156
171
  */
157
- function getAllUserColumns(metadata) {
172
+ export function getAllUserColumns(metadata) {
158
173
  return Object.keys(metadata).filter(col => !shouldSkipColumn(col));
159
174
  }
160
175
 
@@ -164,7 +179,7 @@ function getAllUserColumns(metadata) {
164
179
  * @param {string} columnName - Column name
165
180
  * @returns {boolean} - True if should skip
166
181
  */
167
- function shouldSkipColumn(columnName) {
182
+ export function shouldSkipColumn(columnName) {
168
183
  // Skip system columns starting with underscore, UID, and children
169
184
  return columnName.startsWith('_') ||
170
185
  columnName === 'UID' ||
@@ -177,7 +192,7 @@ function shouldSkipColumn(columnName) {
177
192
  * @param {*} value - Value to check
178
193
  * @returns {boolean} - True if reference
179
194
  */
180
- function isReference(value) {
195
+ export function isReference(value) {
181
196
  return typeof value === 'string' && value.startsWith('@');
182
197
  }
183
198
 
@@ -189,7 +204,7 @@ function isReference(value) {
189
204
  * @param {string} baseDir - Base directory containing metadata
190
205
  * @returns {string} - Absolute file path
191
206
  */
192
- function resolveReferencePath(reference, baseDir) {
207
+ export function resolveReferencePath(reference, baseDir) {
193
208
  const refPath = reference.substring(1); // Strip leading @
194
209
  if (refPath.startsWith('/')) {
195
210
  return join(process.cwd(), refPath);
@@ -203,7 +218,7 @@ function resolveReferencePath(reference, baseDir) {
203
218
  * @param {*} value - Value to normalize
204
219
  * @returns {string} - Normalized string value
205
220
  */
206
- function normalizeValue(value) {
221
+ export function normalizeValue(value) {
207
222
  if (value === null || value === undefined) {
208
223
  return '';
209
224
  }
@@ -215,3 +230,75 @@ function normalizeValue(value) {
215
230
 
216
231
  return String(value).trim();
217
232
  }
233
+
234
+ // ─── Compound Output Delta Detection ────────────────────────────────────────
235
+
236
+ const _OUTPUT_DOC_KEYS = ['column', 'join', 'filter'];
237
+
238
+ /**
239
+ * Detect changed columns across a compound output file (root + inline children).
240
+ *
241
+ * @param {string} metaPath - Path to the root output JSON file
242
+ * @param {Object} baseline - The baseline JSON
243
+ * @returns {Promise<{ root: string[], children: Object<string, string[]> }>}
244
+ * root: changed column names on the root output entity
245
+ * children: { uid: [changedCols] } for each inline child entity
246
+ */
247
+ export async function detectOutputChanges(metaPath, baseline) {
248
+ const metaRaw = await readFile(metaPath, 'utf8');
249
+ const meta = JSON.parse(metaRaw);
250
+ const metaDir = dirname(metaPath);
251
+
252
+ // Detect root changes
253
+ const root = await _detectEntityChanges(meta, baseline, metaDir);
254
+
255
+ // Detect child changes recursively via children.{column,join,filter}
256
+ const children = {};
257
+ if (meta.children) {
258
+ await _walkChildrenForChanges(meta.children, baseline, metaDir, children);
259
+ }
260
+
261
+ return { root, children };
262
+ }
263
+
264
+ async function _detectEntityChanges(entity, baseline, metaDir) {
265
+ const { UID, _entity } = entity;
266
+ const baselineEntry = findBaselineEntry(baseline, _entity, UID);
267
+ if (!baselineEntry) {
268
+ return getAllUserColumns(entity);
269
+ }
270
+ const changed = [];
271
+ for (const [col, val] of Object.entries(entity)) {
272
+ if (shouldSkipColumn(col) || col === 'children') continue;
273
+ if (isReference(val)) {
274
+ const refPath = resolveReferencePath(val, metaDir);
275
+ if (await compareFileContent(refPath, baselineEntry[col])) changed.push(col);
276
+ } else {
277
+ if (normalizeValue(val) !== normalizeValue(baselineEntry[col])) {
278
+ // Skip Extension column when baseline is null/empty (same as detectChangedColumns)
279
+ if (col === 'Extension'
280
+ && !normalizeValue(baselineEntry[col])
281
+ && (baselineEntry[col] === null || baselineEntry[col] === undefined)) {
282
+ continue;
283
+ }
284
+ changed.push(col);
285
+ }
286
+ }
287
+ }
288
+ return changed;
289
+ }
290
+
291
+ async function _walkChildrenForChanges(childrenObj, baseline, metaDir, result) {
292
+ for (const docKey of _OUTPUT_DOC_KEYS) {
293
+ const entityArray = childrenObj[docKey];
294
+ if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
295
+ for (const child of entityArray) {
296
+ const changed = await _detectEntityChanges(child, baseline, metaDir);
297
+ result[child.UID] = changed;
298
+ // Recurse into child's own children (e.g. column[0].children.filter)
299
+ if (child.children) {
300
+ await _walkChildrenForChanges(child.children, baseline, metaDir, result);
301
+ }
302
+ }
303
+ }
304
+ }
package/src/lib/diff.js CHANGED
@@ -48,9 +48,15 @@ export async function findMetadataFiles(dir, ig) {
48
48
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
49
49
  if (!ig.ignores(relPath)) results.push(fullPath);
50
50
  } else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
51
- // Output hierarchy files: _output~<name>~<uid>.json and nested entity files
52
- const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
53
- if (!ig.ignores(relPath)) results.push(fullPath);
51
+ // Output hierarchy root files only: _output~<name>~<uid>.json
52
+ // Exclude old-format child output files — they contain a dot-prefixed
53
+ // child-type segment (.column~, .join~, .filter~) before .json.
54
+ // Root output files never have these segments, even when <name> contains dots.
55
+ const isChildFile = /\.(column|join|filter)~/.test(entry.name);
56
+ if (!isChildFile) {
57
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
58
+ if (!ig.ignores(relPath)) results.push(fullPath);
59
+ }
54
60
  }
55
61
  }
56
62
 
@@ -0,0 +1,120 @@
1
+ import { execFile } from 'child_process';
2
+ import { writeFile, stat, access } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { promisify } from 'util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Best-effort: apply the system trash/recycle-bin icon to a folder.
10
+ * Never throws — all errors are silently swallowed.
11
+ *
12
+ * @param {string} folderPath Absolute path to the trash directory
13
+ */
14
+ export async function applyTrashIcon(folderPath) {
15
+ try {
16
+ const s = await stat(folderPath);
17
+ if (!s.isDirectory()) return;
18
+
19
+ switch (process.platform) {
20
+ case 'darwin':
21
+ await applyMacIcon(folderPath);
22
+ break;
23
+ case 'win32':
24
+ await applyWindowsIcon(folderPath);
25
+ break;
26
+ case 'linux':
27
+ await applyLinuxIcon(folderPath);
28
+ break;
29
+ }
30
+ } catch {
31
+ // Best-effort — never propagate
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Re-apply the trash icon only if the icon marker file is missing.
37
+ * Much cheaper than applyTrashIcon — skips the osascript/attrib call
38
+ * when the icon is already in place.
39
+ *
40
+ * Call this after moving files into trash/ to self-heal the icon
41
+ * in case the user cleared the directory contents.
42
+ *
43
+ * @param {string} folderPath Absolute path to the trash directory
44
+ */
45
+ export async function ensureTrashIcon(folderPath) {
46
+ try {
47
+ switch (process.platform) {
48
+ case 'darwin': {
49
+ // macOS: Icon\r file exists when icon is applied
50
+ const iconFile = join(folderPath, 'Icon\r');
51
+ try { await access(iconFile); return; } catch { /* missing — re-apply */ }
52
+ await applyMacIcon(folderPath);
53
+ break;
54
+ }
55
+ case 'win32': {
56
+ const iniPath = join(folderPath, 'desktop.ini');
57
+ try { await access(iniPath); return; } catch { /* missing */ }
58
+ await applyWindowsIcon(folderPath);
59
+ break;
60
+ }
61
+ case 'linux': {
62
+ const dirFile = join(folderPath, '.directory');
63
+ try { await access(dirFile); return; } catch { /* missing */ }
64
+ await applyLinuxIcon(folderPath);
65
+ break;
66
+ }
67
+ }
68
+ } catch {
69
+ // Best-effort — never propagate
70
+ }
71
+ }
72
+
73
+ // ── macOS ──────────────────────────────────────────────────────────────
74
+
75
+ const MACOS_TRASH_ICON =
76
+ '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/TrashIcon.icns';
77
+
78
+ async function applyMacIcon(folderPath) {
79
+ await access(MACOS_TRASH_ICON);
80
+
81
+ const script =
82
+ 'use framework "AppKit"\n' +
83
+ `set iconImage to (current application's NSImage's alloc()'s initWithContentsOfFile:"${MACOS_TRASH_ICON}")\n` +
84
+ `(current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:"${folderPath}" options:0)`;
85
+
86
+ await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
87
+ }
88
+
89
+ // ── Windows ────────────────────────────────────────────────────────────
90
+
91
+ async function applyWindowsIcon(folderPath) {
92
+ const iniPath = join(folderPath, 'desktop.ini');
93
+ const iniContent =
94
+ '[.ShellClassInfo]\r\nIconResource=%SystemRoot%\\System32\\shell32.dll,31\r\n';
95
+
96
+ await writeFile(iniPath, iniContent);
97
+ await execFileAsync('attrib', ['+H', '+S', iniPath], { timeout: 5000 });
98
+ await execFileAsync('attrib', ['+S', folderPath], { timeout: 5000 });
99
+ }
100
+
101
+ // ── Linux ──────────────────────────────────────────────────────────────
102
+
103
+ async function applyLinuxIcon(folderPath) {
104
+ // KDE Dolphin — .directory file
105
+ await writeFile(
106
+ join(folderPath, '.directory'),
107
+ '[Desktop Entry]\nIcon=user-trash\n',
108
+ );
109
+
110
+ // GNOME Nautilus — gio metadata (may not be available on KDE-only systems)
111
+ try {
112
+ await execFileAsync(
113
+ 'gio',
114
+ ['set', folderPath, 'metadata::custom-icon-name', 'user-trash'],
115
+ { timeout: 5000 },
116
+ );
117
+ } catch {
118
+ // gio not available — .directory file is enough for KDE
119
+ }
120
+ }
package/src/lib/ignore.js CHANGED
@@ -16,12 +16,12 @@ const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
16
16
  .dboignore
17
17
  *.dboio.json
18
18
  app.json
19
- .app.json
20
19
  dbo.deploy.json
21
20
 
22
21
  # Editor / IDE / OS
23
22
  .DS_Store
24
23
  Thumbs.db
24
+ Icon\\r
25
25
  .idea/
26
26
  .vscode/
27
27
  *.codekit3
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { log } from './logger.js';
3
- import { loadUserInfo, loadTicketSuggestionOutput } from './config.js';
3
+ import { loadConfig, loadUserInfo, loadTicketSuggestionOutput } from './config.js';
4
4
  import { buildTicketExpression, setGlobalTicket, setRecordTicket, getGlobalTicket, getRecordTicket, setTicketingRequired } from './ticketing.js';
5
5
  import { DboClient } from './client.js';
6
6
 
@@ -186,7 +186,6 @@ export async function checkSubmitErrors(result, context = {}) {
186
186
  if (needsUser && !userResolved) {
187
187
  log.warn(`This operation requires an authenticated user (${matchedUserPattern}).`);
188
188
  log.dim(' Your session may have expired, or you may not be logged in.');
189
- log.dim(' You can log in with "dbo login" to avoid this prompt in the future.');
190
189
 
191
190
  const stored = await loadUserInfo();
192
191
 
@@ -197,6 +196,7 @@ export async function checkSubmitErrors(result, context = {}) {
197
196
  name: 'userChoice',
198
197
  message: 'User ID Required:',
199
198
  choices: [
199
+ { name: 'Re-login now (recommended)', value: '_relogin' },
200
200
  { name: `Use session user (ID: ${stored.userId})`, value: stored.userId },
201
201
  { name: 'Enter a different User ID', value: '_custom' },
202
202
  ],
@@ -210,9 +210,19 @@ export async function checkSubmitErrors(result, context = {}) {
210
210
  });
211
211
  } else {
212
212
  prompts.push({
213
- type: 'input',
214
- name: 'userValue',
213
+ type: 'list',
214
+ name: 'userChoice',
215
215
  message: 'User ID Required:',
216
+ choices: [
217
+ { name: 'Re-login now', value: '_relogin' },
218
+ { name: 'Enter User ID manually', value: '_custom' },
219
+ ],
220
+ });
221
+ prompts.push({
222
+ type: 'input',
223
+ name: 'customUserValue',
224
+ message: 'User ID:',
225
+ when: (answers) => answers.userChoice === '_custom',
216
226
  validate: v => v.trim() ? true : 'User ID is required',
217
227
  });
218
228
  }
@@ -233,12 +243,29 @@ export async function checkSubmitErrors(result, context = {}) {
233
243
 
234
244
  if (answers.ticketId) retryParams['_OverrideTicketID'] = answers.ticketId.trim();
235
245
 
236
- const userValue = answers.userValue
237
- || (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
238
- if (userValue) {
239
- const resolved = userValue.trim();
240
- retryParams['_OverrideUserID'] = resolved;
241
- _sessionUserOverride = resolved; // cache for remaining records in this batch
246
+ if (answers.userChoice === '_relogin') {
247
+ // Run login flow inline, then use the stored user ID
248
+ try {
249
+ const config = await loadConfig();
250
+ const { performLogin } = await import('../commands/login.js');
251
+ await performLogin(config.domain);
252
+ const refreshed = await loadUserInfo();
253
+ if (refreshed.userId) {
254
+ retryParams['_OverrideUserID'] = refreshed.userId;
255
+ _sessionUserOverride = refreshed.userId;
256
+ }
257
+ } catch (err) {
258
+ log.error(`Re-login failed: ${err.message}`);
259
+ return null;
260
+ }
261
+ } else {
262
+ const userValue = answers.userValue
263
+ || (answers.userChoice === '_custom' ? answers.customUserValue : answers.userChoice);
264
+ if (userValue) {
265
+ const resolved = userValue.trim();
266
+ retryParams['_OverrideUserID'] = resolved;
267
+ _sessionUserOverride = resolved;
268
+ }
242
269
  }
243
270
 
244
271
  return retryParams;