@dboio/cli 0.6.14 → 0.8.0

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.
@@ -116,7 +116,7 @@ const deployCmd = new Command('deploy')
116
116
  }
117
117
 
118
118
  // Retry with prompted params if needed (ticket, user, repo mismatch)
119
- const errorResult = await checkSubmitErrors(result);
119
+ const errorResult = await checkSubmitErrors(result, { rowUid: uid });
120
120
  if (errorResult) {
121
121
  if (errorResult.skipRecord || errorResult.skipAll) {
122
122
  log.info('Submission cancelled');
@@ -156,7 +156,7 @@ export const deployCommand = new Command('deploy')
156
156
  }
157
157
 
158
158
  // Retry with prompted params if needed (ticket, user, repo mismatch)
159
- const errorResult = await checkSubmitErrors(result);
159
+ const errorResult = await checkSubmitErrors(result, { rowUid: uid });
160
160
  if (errorResult) {
161
161
  if (errorResult.skipRecord) {
162
162
  log.warn(` Skipping "${entryName}"`);
@@ -1,9 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import { access, readdir } from 'fs/promises';
3
3
  import { join } from 'path';
4
- import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset } from '../lib/config.js';
4
+ import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
5
5
  import { installOrUpdateClaudeCommands } from './install.js';
6
6
  import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
7
+ import { createDboignore, loadIgnore } from '../lib/ignore.js';
7
8
  import { log } from '../lib/logger.js';
8
9
  import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
9
10
 
@@ -19,10 +20,22 @@ export const initCommand = new Command('init')
19
20
  .option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
20
21
  .option('--local', 'Install Claude commands to project directory (.claude/commands/)')
21
22
  .option('--scaffold', 'Create standard project directories (App Versions, Automations, Bins, …)')
23
+ .option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
22
24
  .action(async (options) => {
23
25
  // Merge --yes into nonInteractive
24
26
  if (options.yes) options.nonInteractive = true;
25
27
  try {
28
+ // --dboignore: standalone operation, works regardless of init state
29
+ if (options.dboignore) {
30
+ const created = await createDboignore(process.cwd(), { force: options.force });
31
+ if (created) {
32
+ log.success(options.force ? 'Reset .dboignore to default patterns' : 'Created .dboignore with default patterns');
33
+ } else {
34
+ log.warn('.dboignore already exists. Use --force to overwrite with defaults.');
35
+ }
36
+ return;
37
+ }
38
+
26
39
  if (await isInitialized() && !options.force) {
27
40
  if (options.scaffold) {
28
41
  const result = await scaffoldProjectDirs();
@@ -86,6 +99,9 @@ export const initCommand = new Command('init')
86
99
  // Ensure sensitive files are gitignored
87
100
  await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
88
101
 
102
+ const createdIgnore = await createDboignore();
103
+ if (createdIgnore) log.dim(' Created .dboignore');
104
+
89
105
  log.success(`Initialized .dbo/ for ${domain}`);
90
106
  log.dim(' Run "dbo login" to authenticate.');
91
107
 
@@ -107,13 +123,32 @@ export const initCommand = new Command('init')
107
123
  await saveTransactionKeyPreset('RowUID');
108
124
  }
109
125
 
126
+ // Prompt for TicketSuggestionOutput
127
+ const DEFAULT_TICKET_SUGGESTION_OUTPUT = 'ojaie9t3o0kfvliahnuuda';
128
+ if (!options.nonInteractive) {
129
+ const inquirer = (await import('inquirer')).default;
130
+ const { ticketSuggestionOutput } = await inquirer.prompt([{
131
+ type: 'input',
132
+ name: 'ticketSuggestionOutput',
133
+ message: 'Output UID for ticket suggestions (leave blank to skip):',
134
+ default: DEFAULT_TICKET_SUGGESTION_OUTPUT,
135
+ }]);
136
+ const tsVal = ticketSuggestionOutput.trim();
137
+ if (tsVal) {
138
+ await saveTicketSuggestionOutput(tsVal);
139
+ log.dim(` TicketSuggestionOutput: ${tsVal}`);
140
+ }
141
+ } else {
142
+ await saveTicketSuggestionOutput(DEFAULT_TICKET_SUGGESTION_OUTPUT);
143
+ }
144
+
110
145
  // ─── Scaffold ─────────────────────────────────────────────────────────────
111
146
  let shouldScaffold = options.scaffold;
112
147
 
113
148
  if (!shouldScaffold && !options.nonInteractive) {
114
- const entries = await readdir(process.cwd());
115
- const IGNORED = new Set(['.dbo', '.claude', '.idea', '.vscode']);
116
- const isEmpty = entries.every(e => IGNORED.has(e));
149
+ const entries = await readdir(process.cwd(), { withFileTypes: true });
150
+ const ig = await loadIgnore();
151
+ const isEmpty = entries.every(e => ig.ignores(e.isDirectory() ? e.name + '/' : e.name));
117
152
 
118
153
  const inquirer = (await import('inquirer')).default;
119
154
  const { doScaffold } = await inquirer.prompt([{
@@ -235,7 +235,7 @@ async function promptForScope(pluginName) {
235
235
 
236
236
  /**
237
237
  * Resolve the scope for a plugin based on flags and stored preferences.
238
- * Priority: explicit flag > stored preference > prompt.
238
+ * Priority: explicit flag > stored preference > existing installation > prompt.
239
239
  * @param {string} pluginName - Plugin name without .md
240
240
  * @param {object} options - Command options with global/local flags
241
241
  * @returns {Promise<'project' | 'global'>}
@@ -247,6 +247,15 @@ async function resolvePluginScope(pluginName, options) {
247
247
  const storedScope = await getPluginScope(pluginName);
248
248
  if (storedScope) return storedScope;
249
249
 
250
+ // Infer from existing installation — avoids re-prompting on re-installs
251
+ // (e.g. postinstall after npm install when .dbo/ isn't in cwd)
252
+ const registry = await readPluginRegistry();
253
+ const key = `${pluginName}@${PLUGIN_MARKETPLACE}`;
254
+ if (registry.plugins[key]) return 'global';
255
+
256
+ const projectPluginDir = join(process.cwd(), '.claude', 'plugins', pluginName);
257
+ if (existsSync(projectPluginDir)) return 'project';
258
+
250
259
  return await promptForScope(pluginName);
251
260
  }
252
261
 
@@ -50,22 +50,17 @@ export const loginCommand = new Command('login')
50
50
  // Ensure sensitive files are gitignored
51
51
  await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
52
52
 
53
- // Fetch current user info to store ID and UID for future submissions
53
+ // Fetch current user info to store ID for future submissions
54
54
  try {
55
55
  const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_format': 'json_raw' });
56
56
  const userData = userResult.payload || userResult.data;
57
57
  const rows = Array.isArray(userData) ? userData : (userData?.Rows || userData?.rows || []);
58
58
  if (rows.length > 0) {
59
59
  const row = rows[0];
60
- const userUid = row.UID || row.uid;
61
60
  const userId = row.ID || row.id || row.UserID || row.userId;
62
- const info = {};
63
- if (userUid) info.userUid = userUid;
64
- if (userId) info.userId = String(userId);
65
- if (info.userUid || info.userId) {
66
- await saveUserInfo(info);
67
- if (info.userId) log.dim(` User ID: ${info.userId}`);
68
- if (info.userUid) log.dim(` User UID: ${info.userUid}`);
61
+ if (userId) {
62
+ await saveUserInfo({ userId: String(userId) });
63
+ log.dim(` User ID: ${userId}`);
69
64
  }
70
65
 
71
66
  // Save user profile (name, email) for package.json population
@@ -25,6 +25,22 @@ export const outputCommand = new Command('output')
25
25
  .option('--template <value>', 'Custom template')
26
26
  .option('--debug', 'Include debug info')
27
27
  .option('--debug-sql', 'Include SQL debug info')
28
+ .option('--debug-verbose', 'Verbose debug output')
29
+ .option('--debug-analysis', 'Analysis debug output')
30
+ .option('--limit <n>', 'Maximum rows to return (_limit)')
31
+ .option('--rowcount <bool>', 'Include row count: true (default) or false for performance')
32
+ .option('--display <expr>', 'Show/hide template tags (repeatable), e.g., "sidebar=hide"', collect, [])
33
+ .option('--format-values', 'Enable value formatting in json_raw output')
34
+ .option('--empty-response-code <code>', 'HTTP status code when output returns no results')
35
+ .option('--fallback-content <expr>', 'Fallback content UID for error codes, e.g., "404=contentUID"')
36
+ .option('--escape-html <bool>', 'Control HTML escaping: true or false')
37
+ .option('--mime <type>', 'Override MIME/content type')
38
+ .option('--strict', 'Strict error mode')
39
+ .option('--confirm', 'Confirmation flag')
40
+ .option('--include <expr>', 'Include token')
41
+ .option('--no-transaction', 'Disable transaction wrapping')
42
+ .option('--skip <phase>', 'Skip execution phases (repeatable, admin-only)', collect, [])
43
+ .option('--profile', 'Enable MiniProfiler output')
28
44
  .option('--meta', 'Use meta output endpoint')
29
45
  .option('--meta-column <uid>', 'Column metadata')
30
46
  .option('--save', 'Interactive save-to-disk mode')
@@ -66,8 +82,45 @@ export const outputCommand = new Command('output')
66
82
  if (options.maxrows) params['_maxrows'] = options.maxrows;
67
83
  if (options.rows) params['_rows'] = options.rows;
68
84
  if (options.template) params['_template'] = options.template;
85
+ if (options.limit) params['_limit'] = options.limit;
86
+ if (options.rowcount !== undefined) params['_rowcount'] = options.rowcount;
69
87
  if (options.debug) params['_debug'] = 'true';
70
88
  if (options.debugSql) params['_debug_sql'] = 'true';
89
+ if (options.debugVerbose) params['_debug:verbose'] = 'true';
90
+ if (options.debugAnalysis) params['_debug:analysis'] = 'true';
91
+ if (options.formatValues) params['_format_values'] = 'true';
92
+ if (options.emptyResponseCode) params['_empty_response_code'] = options.emptyResponseCode;
93
+ if (options.escapeHtml !== undefined) params['_escape_html'] = options.escapeHtml;
94
+ if (options.mime) params['_mime'] = options.mime;
95
+ if (options.strict) params['_strict'] = 'true';
96
+ if (options.confirm) params['_confirm'] = 'true';
97
+ if (options.include) params['_include'] = options.include;
98
+ if (options.transaction === false) params['_no_transaction'] = 'true';
99
+ if (options.profile) params['_profile'] = 'true';
100
+
101
+ // --display: "tagName=show|hide" → _display@tagName=show|hide
102
+ for (const d of options.display) {
103
+ const eqIdx = d.indexOf('=');
104
+ if (eqIdx !== -1) {
105
+ params[`_display@${d.substring(0, eqIdx)}`] = d.substring(eqIdx + 1);
106
+ }
107
+ }
108
+
109
+ // --fallback-content: "404=contentUID" → _fallback_content:404=contentUID
110
+ if (options.fallbackContent) {
111
+ const fb = options.fallbackContent;
112
+ if (fb.includes('=')) {
113
+ const [code, uid] = fb.split('=', 2);
114
+ params[`_fallback_content:${code}`] = uid;
115
+ } else {
116
+ params['_fallback_content'] = fb;
117
+ }
118
+ }
119
+
120
+ // --skip: repeatable → array
121
+ if (options.skip.length > 0) {
122
+ params['_skip'] = options.skip;
123
+ }
71
124
 
72
125
  for (const f of options.filter) {
73
126
  const atIdx = f.indexOf('=');
@@ -84,9 +137,9 @@ export const outputCommand = new Command('output')
84
137
  }
85
138
  }
86
139
 
87
- for (const s of options.sort) {
88
- const [col, dir] = s.includes(':') ? s.split(':') : [s, 'asc'];
89
- params[`_sort@${col}`] = dir;
140
+ // Pass sort values through directly — API expects _sort=Column:DIR
141
+ if (options.sort.length > 0) {
142
+ params['_sort'] = options.sort;
90
143
  }
91
144
 
92
145
  // For save mode or jq mode, force json_raw if user didn't set a custom format
@@ -40,9 +40,9 @@ export const pullCommand = new Command('pull')
40
40
  }
41
41
  }
42
42
 
43
- for (const s of options.sort) {
44
- const [col, dir] = s.includes(':') ? s.split(':') : [s, 'asc'];
45
- params[`_sort@${col}`] = dir;
43
+ // Pass sort values through directly — API expects _sort=Column:DIR
44
+ if (options.sort.length > 0) {
45
+ params['_sort'] = options.sort;
46
46
  }
47
47
 
48
48
  let path;
@@ -13,7 +13,19 @@ import { resolveTransactionKey } from '../lib/transaction-key.js';
13
13
  import { setFileTimestamps } from '../lib/timestamps.js';
14
14
  import { findMetadataFiles } from '../lib/diff.js';
15
15
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
16
- import { buildDependencyGraph } from '../lib/dependencies.js';
16
+
17
+ /**
18
+ * Resolve an @reference file path to an absolute filesystem path.
19
+ * "@filename.ext" → relative to the metadata file's directory (existing behaviour)
20
+ * "@/Documentation/..." → relative to project root (process.cwd())
21
+ */
22
+ function resolveAtReference(refFile, metaDir) {
23
+ if (refFile.startsWith('/')) {
24
+ return join(process.cwd(), refFile);
25
+ }
26
+ return join(metaDir, refFile);
27
+ }
28
+ import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
17
29
 
18
30
  export const pushCommand = new Command('push')
19
31
  .description('Push local files back to DBO.io using metadata from pull')
@@ -214,7 +226,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
214
226
  for (const col of contentCols) {
215
227
  const ref = meta[col];
216
228
  if (ref && ref.startsWith('@')) {
217
- const refPath = join(dirname(metaPath), ref.substring(1));
229
+ const refPath = resolveAtReference(ref.substring(1), dirname(metaPath));
218
230
  try {
219
231
  await stat(refPath);
220
232
  } catch {
@@ -262,13 +274,12 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
262
274
  }
263
275
  }
264
276
 
265
- // Group by entity and apply dependency ordering
266
- const byEntity = {};
267
- for (const item of toPush) {
268
- const entity = item.meta._entity;
269
- if (!byEntity[entity]) byEntity[entity] = [];
270
- byEntity[entity].push(item);
271
- }
277
+ // Sort by dependency level: children first (ascending level) for add/edit operations
278
+ toPush.sort((a, b) => {
279
+ const levelA = ENTITY_DEPENDENCIES[a.meta._entity] || 0;
280
+ const levelB = ENTITY_DEPENDENCIES[b.meta._entity] || 0;
281
+ return levelA - levelB;
282
+ });
272
283
 
273
284
  // Process in dependency order
274
285
  let succeeded = 0;
@@ -361,6 +372,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
361
372
  for (const [key, value] of Object.entries(meta)) {
362
373
  if (shouldSkipColumn(key)) continue;
363
374
  if (key === 'UID') continue; // UID is the identifier, not a column to update
375
+ if (key === 'children') continue; // Output hierarchy structural field, not a server column
364
376
  if (value === null || value === undefined) continue;
365
377
 
366
378
  // Delta sync: skip columns not in changedColumns
@@ -378,7 +390,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
378
390
  if (strValue.startsWith('@')) {
379
391
  // @filename reference — resolve to actual file path
380
392
  const refFile = strValue.substring(1);
381
- const refPath = join(metaDir, refFile);
393
+ const refPath = resolveAtReference(refFile, metaDir);
382
394
  dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}@${refPath}`);
383
395
  } else {
384
396
  dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}=${strValue}`);
@@ -413,7 +425,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
413
425
  }
414
426
 
415
427
  // Retry with prompted params if needed (ticket, user, repo mismatch)
416
- const retryResult = await checkSubmitErrors(result);
428
+ const retryResult = await checkSubmitErrors(result, { rowUid: uid });
417
429
  if (retryResult) {
418
430
  // Handle skip actions
419
431
  if (retryResult.skipRecord) {
@@ -647,7 +659,7 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
647
659
  if (strValue.startsWith('@')) {
648
660
  try {
649
661
  const refFile = strValue.substring(1);
650
- const refPath = join(dirname(metaPath), refFile);
662
+ const refPath = resolveAtReference(refFile, dirname(metaPath));
651
663
  const fileContent = await readFile(refPath, 'utf8');
652
664
  baselineEntry[col] = fileContent;
653
665
  modified = true;
package/src/lib/config.js CHANGED
@@ -69,7 +69,7 @@ export async function initConfig(domain) {
69
69
 
70
70
  export async function saveCredentials(username) {
71
71
  await mkdir(dboDir(), { recursive: true });
72
- // Preserve existing fields (like userUid) — never store password
72
+ // Preserve existing fields (like userId) — never store password
73
73
  let existing = {};
74
74
  try {
75
75
  existing = JSON.parse(await readFile(credentialsPath(), 'utf8'));
@@ -79,14 +79,13 @@ export async function saveCredentials(username) {
79
79
  await writeFile(credentialsPath(), JSON.stringify(existing, null, 2) + '\n');
80
80
  }
81
81
 
82
- export async function saveUserInfo({ userId, userUid }) {
82
+ export async function saveUserInfo({ userId }) {
83
83
  await mkdir(dboDir(), { recursive: true });
84
84
  let existing = {};
85
85
  try {
86
86
  existing = JSON.parse(await readFile(credentialsPath(), 'utf8'));
87
87
  } catch { /* no existing file */ }
88
88
  if (userId !== undefined) existing.userId = userId;
89
- if (userUid !== undefined) existing.userUid = userUid;
90
89
  await writeFile(credentialsPath(), JSON.stringify(existing, null, 2) + '\n');
91
90
  }
92
91
 
@@ -94,12 +93,9 @@ export async function loadUserInfo() {
94
93
  try {
95
94
  const raw = await readFile(credentialsPath(), 'utf8');
96
95
  const creds = JSON.parse(raw);
97
- return {
98
- userId: creds.userId || null,
99
- userUid: creds.userUid || null,
100
- };
96
+ return { userId: creds.userId || null };
101
97
  } catch {
102
- return { userId: null, userUid: null };
98
+ return { userId: null };
103
99
  }
104
100
  }
105
101
 
@@ -529,6 +525,50 @@ export async function getAllPluginScopes() {
529
525
  return result;
530
526
  }
531
527
 
528
+ // ─── Output Hierarchy Filename Preferences ────────────────────────────────
529
+
530
+ /**
531
+ * Config keys for output hierarchy filename column preferences.
532
+ * Maps physical table names to config key names.
533
+ */
534
+ const OUTPUT_FILENAME_CONFIG_KEYS = {
535
+ output: 'OutputFilenameCol',
536
+ output_value: 'OutputColumnFilenameCol',
537
+ output_value_filter: 'OutputFilterFilenameCol',
538
+ output_value_entity_column_rel: 'OutputJoinFilenameCol',
539
+ };
540
+
541
+ /**
542
+ * Load output filename column preference for an entity type.
543
+ * @param {string} entityKey - Physical table name (e.g., 'output', 'output_value')
544
+ * @returns {Promise<string|null>}
545
+ */
546
+ export async function loadOutputFilenamePreference(entityKey) {
547
+ try {
548
+ const raw = await readFile(configPath(), 'utf8');
549
+ const config = JSON.parse(raw);
550
+ const configKey = OUTPUT_FILENAME_CONFIG_KEYS[entityKey];
551
+ return configKey ? (config[configKey] || null) : null;
552
+ } catch {
553
+ return null;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Save output filename column preference for an entity type.
559
+ * @param {string} entityKey - Physical table name
560
+ * @param {string} columnName - Column name to use as filename
561
+ */
562
+ export async function saveOutputFilenamePreference(entityKey, columnName) {
563
+ const configKey = OUTPUT_FILENAME_CONFIG_KEYS[entityKey];
564
+ if (!configKey) return;
565
+ await mkdir(dboDir(), { recursive: true });
566
+ let existing = {};
567
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
568
+ existing[configKey] = columnName;
569
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
570
+ }
571
+
532
572
  // ─── AppModifyKey ─────────────────────────────────────────────────────────
533
573
 
534
574
  /**
@@ -580,6 +620,32 @@ export async function loadTransactionKeyPreset() {
580
620
  } catch { return null; }
581
621
  }
582
622
 
623
+ // ─── TicketSuggestionOutput ────────────────────────────────────────────────
624
+
625
+ /**
626
+ * Save TicketSuggestionOutput to .dbo/config.json.
627
+ * This is the output UID used to fetch ticket suggestions.
628
+ */
629
+ export async function saveTicketSuggestionOutput(outputUid) {
630
+ await mkdir(dboDir(), { recursive: true });
631
+ let existing = {};
632
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
633
+ if (outputUid != null) existing.TicketSuggestionOutput = outputUid;
634
+ else delete existing.TicketSuggestionOutput;
635
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
636
+ }
637
+
638
+ /**
639
+ * Load TicketSuggestionOutput from .dbo/config.json.
640
+ * Returns the output UID string or null if not set.
641
+ */
642
+ export async function loadTicketSuggestionOutput() {
643
+ try {
644
+ const raw = await readFile(configPath(), 'utf8');
645
+ return JSON.parse(raw).TicketSuggestionOutput || null;
646
+ } catch { return null; }
647
+ }
648
+
583
649
  // ─── Gitignore ────────────────────────────────────────────────────────────
584
650
 
585
651
  /**
@@ -658,3 +724,104 @@ export async function loadAppJsonBaseline() {
658
724
  export async function saveAppJsonBaseline(data) {
659
725
  await writeFile(baselinePath(), JSON.stringify(data, null, 2) + '\n');
660
726
  }
727
+
728
+ /**
729
+ * Save the clone source to .dbo/config.json.
730
+ * "default" = fetched from server via AppShortName.
731
+ * Any other value = explicit local file path or URL provided by the user.
732
+ */
733
+ export async function saveCloneSource(source) {
734
+ await mkdir(dboDir(), { recursive: true });
735
+ let existing = {};
736
+ try {
737
+ existing = JSON.parse(await readFile(configPath(), 'utf8'));
738
+ } catch { /* no existing config */ }
739
+ existing.cloneSource = source;
740
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
741
+ }
742
+
743
+ /**
744
+ * Load the stored clone source from .dbo/config.json.
745
+ * Returns null if not set.
746
+ */
747
+ export async function loadCloneSource() {
748
+ try {
749
+ const raw = await readFile(configPath(), 'utf8');
750
+ const config = JSON.parse(raw);
751
+ return config.cloneSource || null;
752
+ } catch {
753
+ return null;
754
+ }
755
+ }
756
+
757
+ // ─── Descriptor-level Extension Preferences ───────────────────────────────
758
+
759
+ /** Save filename column preference for a specific Descriptor value.
760
+ * Config key: "Extension_<descriptor>_FilenameCol"
761
+ */
762
+ export async function saveDescriptorFilenamePreference(descriptor, columnName) {
763
+ await mkdir(dboDir(), { recursive: true });
764
+ let cfg = {};
765
+ try { cfg = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
766
+ if (columnName === null) {
767
+ delete cfg[`Extension_${descriptor}_FilenameCol`];
768
+ } else {
769
+ cfg[`Extension_${descriptor}_FilenameCol`] = columnName;
770
+ }
771
+ await writeFile(configPath(), JSON.stringify(cfg, null, 2) + '\n');
772
+ }
773
+
774
+ /** Load filename column preference for a specific Descriptor value. Returns null if not set. */
775
+ export async function loadDescriptorFilenamePreference(descriptor) {
776
+ try {
777
+ const cfg = JSON.parse(await readFile(configPath(), 'utf8'));
778
+ return cfg[`Extension_${descriptor}_FilenameCol`] || null;
779
+ } catch { return null; }
780
+ }
781
+
782
+ /** Save content extraction preferences for a specific Descriptor value.
783
+ * Config key: "Extension_<descriptor>_ContentExtractions"
784
+ * Value: { "ColName": "css", "Other": false, ... }
785
+ */
786
+ export async function saveDescriptorContentExtractions(descriptor, extractions) {
787
+ await mkdir(dboDir(), { recursive: true });
788
+ let cfg = {};
789
+ try { cfg = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
790
+ if (extractions === null) {
791
+ delete cfg[`Extension_${descriptor}_ContentExtractions`];
792
+ } else {
793
+ cfg[`Extension_${descriptor}_ContentExtractions`] = extractions;
794
+ }
795
+ await writeFile(configPath(), JSON.stringify(cfg, null, 2) + '\n');
796
+ }
797
+
798
+ /** Load content extraction preferences for a specific Descriptor value. Returns null if not saved. */
799
+ export async function loadDescriptorContentExtractions(descriptor) {
800
+ try {
801
+ const cfg = JSON.parse(await readFile(configPath(), 'utf8'));
802
+ return cfg[`Extension_${descriptor}_ContentExtractions`] || null;
803
+ } catch { return null; }
804
+ }
805
+
806
+ /** Save ExtensionDocumentationMDPlacement preference.
807
+ * @param {'inline'|'root'|null} placement — null clears the key
808
+ */
809
+ export async function saveExtensionDocumentationMDPlacement(placement) {
810
+ await mkdir(dboDir(), { recursive: true });
811
+ let cfg = {};
812
+ try { cfg = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
813
+ if (placement === null) {
814
+ delete cfg.ExtensionDocumentationMDPlacement;
815
+ } else {
816
+ cfg.ExtensionDocumentationMDPlacement = placement;
817
+ }
818
+ await writeFile(configPath(), JSON.stringify(cfg, null, 2) + '\n');
819
+ }
820
+
821
+ /** Load ExtensionDocumentationMDPlacement preference. Returns 'inline', 'root', or null. */
822
+ export async function loadExtensionDocumentationMDPlacement() {
823
+ try {
824
+ const cfg = JSON.parse(await readFile(configPath(), 'utf8'));
825
+ return cfg.ExtensionDocumentationMDPlacement || null;
826
+ } catch { return null; }
827
+ }