@dboio/cli 0.7.2 → 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.
@@ -4,6 +4,7 @@ import { join } from 'path';
4
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
 
@@ -130,9 +146,9 @@ export const initCommand = new Command('init')
130
146
  let shouldScaffold = options.scaffold;
131
147
 
132
148
  if (!shouldScaffold && !options.nonInteractive) {
133
- const entries = await readdir(process.cwd());
134
- const IGNORED = new Set(['.dbo', '.claude', '.idea', '.vscode']);
135
- 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));
136
152
 
137
153
  const inquirer = (await import('inquirer')).default;
138
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
 
@@ -13,6 +13,18 @@ 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
+
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
+ }
16
28
  import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
17
29
 
18
30
  export const pushCommand = new Command('push')
@@ -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 {
@@ -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}`);
@@ -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
@@ -724,3 +724,104 @@ export async function loadAppJsonBaseline() {
724
724
  export async function saveAppJsonBaseline(data) {
725
725
  await writeFile(baselinePath(), JSON.stringify(data, null, 2) + '\n');
726
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
+ }
package/src/lib/diff.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { readFile, writeFile, readdir, access, stat } from 'fs/promises';
3
- import { join, dirname, basename, extname } from 'path';
3
+ import { join, dirname, basename, extname, relative } from 'path';
4
+ import { loadIgnore } from './ignore.js';
4
5
  import { parseServerDate, setFileTimestamps } from './timestamps.js';
5
6
  import { loadConfig, loadUserInfo } from './config.js';
6
7
  import { log } from './logger.js';
@@ -31,16 +32,18 @@ async function fileExists(path) {
31
32
  * Recursively find all metadata files in a directory.
32
33
  * Includes .metadata.json files and output hierarchy files (_output~*.json).
33
34
  */
34
- export async function findMetadataFiles(dir) {
35
+ export async function findMetadataFiles(dir, ig) {
36
+ if (!ig) ig = await loadIgnore();
37
+
35
38
  const results = [];
36
39
  const entries = await readdir(dir, { withFileTypes: true });
37
40
 
38
41
  for (const entry of entries) {
39
42
  const fullPath = join(dir, entry.name);
40
43
  if (entry.isDirectory()) {
41
- // Skip hidden dirs, node_modules, .dbo
42
- if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
43
- results.push(...await findMetadataFiles(fullPath));
44
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
45
+ if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
46
+ results.push(...await findMetadataFiles(fullPath, ig));
44
47
  } else if (entry.name.endsWith('.metadata.json')) {
45
48
  results.push(fullPath);
46
49
  } else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
@@ -498,21 +501,67 @@ export async function applyServerChanges(diffResult, acceptedFields, config) {
498
501
  }
499
502
  }
500
503
 
504
+ // ─── Diffability ─────────────────────────────────────────────────────────────
505
+
506
+ // Extensions that can be meaningfully text-diffed
507
+ const DIFFABLE_EXTENSIONS = new Set([
508
+ 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
509
+ 'css', 'scss', 'less', 'sass',
510
+ 'html', 'htm', 'xhtml',
511
+ 'sql', 'xml', 'json', 'yaml', 'yml', 'toml', 'ini', 'env',
512
+ 'md', 'txt', 'csv', 'tsv',
513
+ 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
514
+ 'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go', 'rs', 'swift', 'kt',
515
+ 'vue', 'svelte', 'astro',
516
+ 'graphql', 'gql', 'proto',
517
+ 'htaccess', 'gitignore', 'dockerignore', 'editorconfig',
518
+ ]);
519
+
520
+ /**
521
+ * Returns true if the file extension suggests it can be meaningfully text-diffed.
522
+ * Images, videos, audio, fonts, archives, and other binary formats return false.
523
+ */
524
+ export function isDiffable(ext) {
525
+ if (!ext) return false;
526
+ return DIFFABLE_EXTENSIONS.has(String(ext).toLowerCase().replace(/^\./, ''));
527
+ }
528
+
501
529
  // ─── Change Detection Prompt ────────────────────────────────────────────────
502
530
 
503
531
  /**
504
532
  * Build the change detection message describing who changed the file.
505
533
  */
506
- function buildChangeMessage(recordName, serverRecord, config) {
534
+ function buildChangeMessage(recordName, serverRecord, config, options = {}) {
507
535
  const userInfo = loadUserInfoSync();
508
536
  const updatedBy = serverRecord._LastUpdatedUserID;
509
537
 
538
+ let who;
510
539
  if (updatedBy && userInfo && String(updatedBy) === String(userInfo.userId)) {
511
- return `"${recordName}" was updated on server by you (from another session)`;
540
+ who = 'you (from another session)';
512
541
  } else if (updatedBy) {
513
- return `"${recordName}" was updated on server by user ${updatedBy}`;
542
+ who = `user ${updatedBy}`;
543
+ }
544
+
545
+ const datePart = formatDateHint(options.serverDate, options.localDate);
546
+
547
+ if (who) {
548
+ return `"${recordName}" was updated on server by ${who}${datePart}`;
514
549
  }
515
- return `"${recordName}" has updates newer than your local version`;
550
+ return `"${recordName}" has updates newer than your local version${datePart}`;
551
+ }
552
+
553
+ /**
554
+ * Format a "(server: X, local: Y)" hint when date info is available.
555
+ */
556
+ function formatDateHint(serverDate, localDate) {
557
+ const fmt = (d) => d instanceof Date && !isNaN(d)
558
+ ? d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
559
+ : null;
560
+ const s = fmt(serverDate);
561
+ const l = fmt(localDate);
562
+ if (s && l) return `\n server: ${s} | local: ${l}`;
563
+ if (s) return `\n server: ${s}`;
564
+ return '';
516
565
  }
517
566
 
518
567
  // Sync version for message building (cached)
@@ -524,31 +573,36 @@ function loadUserInfoSync() {
524
573
  /**
525
574
  * Prompt the user when a record has changed.
526
575
  * options.localIsNewer: when true, the local file has modifications not on server.
576
+ * options.diffable: when false, omit the "Compare differences" choice.
577
+ * options.serverDate / options.localDate: Date objects shown as hints.
527
578
  * Returns: 'overwrite' | 'compare' | 'skip' | 'overwrite_all' | 'skip_all'
528
579
  */
529
580
  export async function promptChangeDetection(recordName, serverRecord, config, options = {}) {
530
581
  const localIsNewer = options.localIsNewer || false;
582
+ const diffable = options.diffable !== false; // default true for text records
531
583
 
532
584
  // Cache user info for message building
533
585
  _cachedUserInfo = await loadUserInfo();
534
586
 
587
+ const datePart = formatDateHint(options.serverDate, options.localDate);
588
+
535
589
  const message = localIsNewer
536
- ? `"${recordName}" has local changes not on the server`
537
- : buildChangeMessage(recordName, serverRecord, config);
590
+ ? `"${recordName}" has local changes not on the server${datePart}`
591
+ : buildChangeMessage(recordName, serverRecord, config, options);
538
592
 
539
593
  const inquirer = (await import('inquirer')).default;
540
594
 
541
595
  const choices = localIsNewer
542
596
  ? [
543
597
  { name: 'Restore server version (discard local changes)', value: 'overwrite' },
544
- { name: 'Compare differences (dbo diff)', value: 'compare' },
598
+ ...(diffable ? [{ name: 'Compare differences (dbo diff)', value: 'compare' }] : []),
545
599
  { name: 'Keep local changes', value: 'skip' },
546
600
  { name: 'Restore all to server version', value: 'overwrite_all' },
547
601
  { name: 'Keep all local changes', value: 'skip_all' },
548
602
  ]
549
603
  : [
550
604
  { name: 'Overwrite local file with server version', value: 'overwrite' },
551
- { name: 'Compare differences (dbo diff)', value: 'compare' },
605
+ ...(diffable ? [{ name: 'Compare differences (dbo diff)', value: 'compare' }] : []),
552
606
  { name: 'Skip this file', value: 'skip' },
553
607
  { name: 'Overwrite this and all remaining changed files', value: 'overwrite_all' },
554
608
  { name: 'Skip all remaining changed files', value: 'skip_all' },
@@ -0,0 +1,145 @@
1
+ import { readFile, writeFile, access } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import ignore from 'ignore';
4
+
5
+ const DBOIGNORE_FILE = '.dboignore';
6
+
7
+ /**
8
+ * Default .dboignore file content — shipped with `dbo init`.
9
+ * Gitignore-style syntax. Users can edit after creation.
10
+ */
11
+ const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
12
+ # (gitignore-style syntax — works like .gitignore)
13
+
14
+ # DBO internal
15
+ .dbo/
16
+ .dboignore
17
+ *.dboio.json
18
+ app.json
19
+ .app.json
20
+ dbo.deploy.json
21
+
22
+ # Editor / IDE
23
+ .idea/
24
+ .vscode/
25
+ *.codekit3
26
+
27
+ # Version control
28
+ .git/
29
+ .svn/
30
+ .hg/
31
+ .gitignore
32
+
33
+ # AI / tooling
34
+ .claude/
35
+ .mcp.json
36
+
37
+ # Node
38
+ node_modules/
39
+ package.json
40
+ package-lock.json
41
+
42
+ # Documentation (repo scaffolding)
43
+ SETUP.md
44
+ README.md
45
+ `;
46
+
47
+ // Session-level cache (one process = one command invocation)
48
+ let _cachedIg = null;
49
+ let _cachedRoot = null;
50
+
51
+ /**
52
+ * Return the full default .dboignore file content (with comments).
53
+ */
54
+ export function getDefaultFileContent() {
55
+ return DEFAULT_FILE_CONTENT;
56
+ }
57
+
58
+ /**
59
+ * Extract just the active pattern lines from DEFAULT_FILE_CONTENT.
60
+ */
61
+ function getDefaultPatternLines() {
62
+ return DEFAULT_FILE_CONTENT
63
+ .split('\n')
64
+ .filter(l => l && !l.startsWith('#'));
65
+ }
66
+
67
+ /**
68
+ * Load and return a cached `ignore` instance for the current project.
69
+ * Reads .dboignore if it exists; falls back to built-in defaults.
70
+ *
71
+ * @param {string} [cwd=process.cwd()] - Project root
72
+ * @returns {Promise<import('ignore').Ignore>}
73
+ */
74
+ export async function loadIgnore(cwd = process.cwd()) {
75
+ if (_cachedIg && _cachedRoot === cwd) return _cachedIg;
76
+
77
+ const ig = ignore();
78
+ const filePath = join(cwd, DBOIGNORE_FILE);
79
+
80
+ try {
81
+ const content = await readFile(filePath, 'utf8');
82
+ ig.add(content);
83
+ } catch {
84
+ // No .dboignore — use built-in defaults
85
+ ig.add(getDefaultPatternLines());
86
+ }
87
+
88
+ _cachedIg = ig;
89
+ _cachedRoot = cwd;
90
+ return ig;
91
+ }
92
+
93
+ /**
94
+ * Check whether a relative path should be ignored.
95
+ *
96
+ * @param {string} relativePath - Path relative to project root (forward slashes)
97
+ * @param {string} [cwd=process.cwd()]
98
+ * @returns {Promise<boolean>}
99
+ */
100
+ export async function isIgnored(relativePath, cwd = process.cwd()) {
101
+ const ig = await loadIgnore(cwd);
102
+ return ig.ignores(relativePath);
103
+ }
104
+
105
+ /**
106
+ * Filter an array of relative paths, returning only those NOT ignored.
107
+ *
108
+ * @param {string[]} paths - Relative paths
109
+ * @param {string} [cwd=process.cwd()]
110
+ * @returns {Promise<string[]>}
111
+ */
112
+ export async function filterIgnored(paths, cwd = process.cwd()) {
113
+ const ig = await loadIgnore(cwd);
114
+ return ig.filter(paths);
115
+ }
116
+
117
+ /**
118
+ * Create a .dboignore file with default patterns.
119
+ * Does nothing if the file already exists (unless force=true).
120
+ *
121
+ * @param {string} [cwd=process.cwd()]
122
+ * @param {{ force?: boolean }} [opts]
123
+ * @returns {Promise<boolean>} true if file was created/overwritten
124
+ */
125
+ export async function createDboignore(cwd = process.cwd(), { force = false } = {}) {
126
+ const filePath = join(cwd, DBOIGNORE_FILE);
127
+ if (!force) {
128
+ try {
129
+ await access(filePath);
130
+ return false; // already exists
131
+ } catch { /* doesn't exist — create it */ }
132
+ }
133
+ await writeFile(filePath, DEFAULT_FILE_CONTENT);
134
+ _cachedIg = null;
135
+ _cachedRoot = null;
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Reset the session cache. Call between tests that change cwd or .dboignore.
141
+ */
142
+ export function resetCache() {
143
+ _cachedIg = null;
144
+ _cachedRoot = null;
145
+ }
@@ -179,3 +179,117 @@ export function findBinByPath(dirPath, structure) {
179
179
  export function findChildBins(binId, structure) {
180
180
  return Object.values(structure).filter(e => e.parentBinID === binId);
181
181
  }
182
+
183
+ // ─── Extension Descriptor Sub-directory Support ───────────────────────────
184
+
185
+ /** Root for all extension descriptor-grouped sub-directories */
186
+ export const EXTENSION_DESCRIPTORS_DIR = 'Extensions';
187
+
188
+ /** Extensions that cannot be mapped go here (always created, even if empty) */
189
+ export const EXTENSION_UNSUPPORTED_DIR = 'Extensions/Unsupported';
190
+
191
+ /** Root-level documentation directory for alternate placement */
192
+ export const DOCUMENTATION_DIR = 'Documentation';
193
+
194
+ /**
195
+ * Build a descriptor→dirName mapping from a flat array of extension records.
196
+ * Scans records where Descriptor === "descriptor_definition".
197
+ * Maps String1 (key) → Name (directory name).
198
+ *
199
+ * Rules:
200
+ * - Null/empty String1: skip, push to warnings[]
201
+ * - Null/empty Name: use String1 as the directory name
202
+ * - Duplicate String1: last one wins, push to warnings[]
203
+ *
204
+ * @param {Object[]} extensionRecords
205
+ * @returns {{ mapping: Object<string,string>, warnings: string[] }}
206
+ */
207
+ export function buildDescriptorMapping(extensionRecords) {
208
+ const mapping = {};
209
+ const warnings = [];
210
+
211
+ for (const rec of extensionRecords) {
212
+ if (rec.Descriptor !== 'descriptor_definition') continue;
213
+
214
+ const rawKey = rec.String1;
215
+ // String1 may be a base64-encoded object from the server API
216
+ const key = resolveFieldValue(rawKey);
217
+ if (!key || String(key).trim() === '') {
218
+ warnings.push(`descriptor_definition UID=${rec.UID} has null/empty String1 — skipped`);
219
+ continue;
220
+ }
221
+ const keyStr = String(key).trim();
222
+
223
+ const rawName = rec.Name;
224
+ const nameStr = resolveFieldValue(rawName);
225
+ const dirName = (nameStr && String(nameStr).trim())
226
+ ? String(nameStr).trim()
227
+ : keyStr;
228
+
229
+ if (keyStr in mapping) {
230
+ warnings.push(`Duplicate descriptor_definition key "${keyStr}" — overwriting with UID=${rec.UID}`);
231
+ }
232
+ mapping[keyStr] = dirName;
233
+ }
234
+
235
+ return { mapping, warnings };
236
+ }
237
+
238
+ /**
239
+ * Resolve a field value that may be a base64-encoded object from the server API.
240
+ * Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
241
+ */
242
+ function resolveFieldValue(value) {
243
+ if (value && typeof value === 'object' && !Array.isArray(value)
244
+ && value.encoding === 'base64' && typeof value.value === 'string') {
245
+ return Buffer.from(value.value, 'base64').toString('utf8');
246
+ }
247
+ return value;
248
+ }
249
+
250
+ /**
251
+ * Persist descriptorMapping and extensionDescriptorDirs into .dbo/structure.json.
252
+ * Extends the existing structure object (already contains bin entries).
253
+ *
254
+ * @param {Object} structure - Current structure from loadStructureFile()
255
+ * @param {Object} mapping - descriptor key → dir name
256
+ */
257
+ export async function saveDescriptorMapping(structure, mapping) {
258
+ const descriptorDirs = [
259
+ EXTENSION_UNSUPPORTED_DIR,
260
+ ...Object.values(mapping).map(name => `${EXTENSION_DESCRIPTORS_DIR}/${name}`),
261
+ ];
262
+ const uniqueDirs = [...new Set(descriptorDirs)];
263
+
264
+ const extended = {
265
+ ...structure,
266
+ descriptorMapping: mapping,
267
+ extensionDescriptorDirs: uniqueDirs,
268
+ };
269
+ await saveStructureFile(extended);
270
+ }
271
+
272
+ /**
273
+ * Load descriptorMapping from .dbo/structure.json.
274
+ * Returns {} if not yet persisted.
275
+ */
276
+ export async function loadDescriptorMapping() {
277
+ const structure = await loadStructureFile();
278
+ return structure.descriptorMapping || {};
279
+ }
280
+
281
+ /**
282
+ * Resolve the sub-directory path for a single extension record.
283
+ * Returns "Extensions/<MappedName>" or "Extensions/Unsupported".
284
+ *
285
+ * @param {Object} record - Extension record with a .Descriptor field
286
+ * @param {Object} mapping - descriptor key → dir name (from buildDescriptorMapping)
287
+ * @returns {string}
288
+ */
289
+ export function resolveExtensionSubDir(record, mapping) {
290
+ const descriptor = record.Descriptor;
291
+ if (!descriptor || !mapping[descriptor]) {
292
+ return EXTENSION_UNSUPPORTED_DIR;
293
+ }
294
+ return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
295
+ }