@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.
- package/README.md +128 -8
- package/package.json +3 -2
- package/src/commands/add.js +56 -11
- package/src/commands/clone.js +652 -58
- package/src/commands/init.js +19 -3
- package/src/commands/install.js +10 -1
- package/src/commands/push.js +15 -3
- package/src/lib/config.js +101 -0
- package/src/lib/diff.js +67 -13
- package/src/lib/ignore.js +145 -0
- package/src/lib/structure.js +114 -0
- package/src/lib/timestamps.js +31 -9
package/src/commands/init.js
CHANGED
|
@@ -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
|
|
135
|
-
const isEmpty = entries.every(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([{
|
package/src/commands/install.js
CHANGED
|
@@ -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
|
|
package/src/commands/push.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
42
|
-
if (
|
|
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
|
-
|
|
540
|
+
who = 'you (from another session)';
|
|
512
541
|
} else if (updatedBy) {
|
|
513
|
-
|
|
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
|
+
}
|
package/src/lib/structure.js
CHANGED
|
@@ -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
|
+
}
|