@dboio/cli 0.13.2 → 0.15.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 +57 -0
- package/bin/dbo.js +2 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +76 -74
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +57 -0
- package/plugins/claude/dbo/skills/cli/SKILL.md +2 -1
- package/src/commands/add.js +12 -7
- package/src/commands/clone.js +138 -94
- package/src/commands/deploy.js +9 -2
- package/src/commands/diff.js +4 -4
- package/src/commands/login.js +30 -4
- package/src/commands/mv.js +17 -4
- package/src/commands/push.js +34 -9
- package/src/commands/rm.js +6 -4
- package/src/commands/tag.js +65 -0
- package/src/lib/config.js +28 -0
- package/src/lib/deploy-config.js +137 -0
- package/src/lib/diff.js +5 -4
- package/src/lib/filenames.js +89 -24
- package/src/lib/scaffold.js +1 -1
- package/src/lib/tagging.js +380 -0
- package/src/lib/toe-stepping.js +2 -1
- package/src/migrations/006-remove-uid-companion-filenames.js +3 -3
- package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
- package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
package/src/commands/login.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { loadConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../lib/config.js';
|
|
2
|
+
import { loadConfig, initConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../lib/config.js';
|
|
3
3
|
import { DboClient } from '../lib/client.js';
|
|
4
4
|
import { log } from '../lib/logger.js';
|
|
5
5
|
|
|
@@ -13,13 +13,24 @@ import { log } from '../lib/logger.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export async function performLogin(domain, knownUsername) {
|
|
15
15
|
const config = await loadConfig();
|
|
16
|
+
const inquirer = (await import('inquirer')).default;
|
|
17
|
+
|
|
18
|
+
// Prompt for domain if not provided and not configured
|
|
19
|
+
if (!domain && !config.domain) {
|
|
20
|
+
const { domain: inputDomain } = await inquirer.prompt([
|
|
21
|
+
{ type: 'input', name: 'domain', message: 'Domain (e.g. myapp.dbo.io):' },
|
|
22
|
+
]);
|
|
23
|
+
if (!inputDomain) throw new Error('Domain is required.');
|
|
24
|
+
domain = inputDomain.trim();
|
|
25
|
+
await initConfig(domain);
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
const client = new DboClient({ domain });
|
|
17
29
|
|
|
18
30
|
let username = knownUsername || config.username;
|
|
19
31
|
let password;
|
|
20
32
|
|
|
21
33
|
// Interactive prompt for missing credentials
|
|
22
|
-
const inquirer = (await import('inquirer')).default;
|
|
23
34
|
const answers = await inquirer.prompt([
|
|
24
35
|
{ type: 'input', name: 'username', message: 'Username (email):', default: username || undefined, when: !username },
|
|
25
36
|
{ type: 'password', name: 'password', message: 'Password:', mask: '*' },
|
|
@@ -83,7 +94,23 @@ export const loginCommand = new Command('login')
|
|
|
83
94
|
.action(async (options) => {
|
|
84
95
|
try {
|
|
85
96
|
const config = await loadConfig();
|
|
86
|
-
const
|
|
97
|
+
const inquirer = (await import('inquirer')).default;
|
|
98
|
+
let domain = options.domain;
|
|
99
|
+
|
|
100
|
+
// Prompt for domain if not provided and not configured
|
|
101
|
+
if (!domain && !config.domain) {
|
|
102
|
+
const { domain: inputDomain } = await inquirer.prompt([
|
|
103
|
+
{ type: 'input', name: 'domain', message: 'Domain (e.g. myapp.dbo.io):' },
|
|
104
|
+
]);
|
|
105
|
+
if (!inputDomain) {
|
|
106
|
+
log.error('Domain is required.');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
domain = inputDomain.trim();
|
|
110
|
+
await initConfig(domain);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const client = new DboClient({ domain });
|
|
87
114
|
|
|
88
115
|
let username = options.username || options.email || options.phone;
|
|
89
116
|
let password = options.password || options.passkey;
|
|
@@ -93,7 +120,6 @@ export const loginCommand = new Command('login')
|
|
|
93
120
|
|
|
94
121
|
// Interactive prompt if still missing
|
|
95
122
|
if (!username || !password) {
|
|
96
|
-
const inquirer = (await import('inquirer')).default;
|
|
97
123
|
const answers = await inquirer.prompt([
|
|
98
124
|
{ type: 'input', name: 'username', message: 'Username (email):', default: config.username, when: !username },
|
|
99
125
|
{ type: 'password', name: 'password', message: 'Password:', mask: '*', when: !password },
|
package/src/commands/mv.js
CHANGED
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
BINS_DIR
|
|
14
14
|
} from '../lib/structure.js';
|
|
15
15
|
import { findMetadataFiles } from '../lib/diff.js';
|
|
16
|
-
import { findMetadataForCompanion } from '../lib/filenames.js';
|
|
16
|
+
import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
|
|
17
17
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
18
|
+
import { removeDeployEntry, upsertDeployEntry } from '../lib/deploy-config.js';
|
|
18
19
|
|
|
19
20
|
export const mvCommand = new Command('mv')
|
|
20
21
|
.description('Move files or bins to a new location and update metadata')
|
|
@@ -200,7 +201,7 @@ function checkCircularReference(sourceBinId, targetBinId, structure) {
|
|
|
200
201
|
* Resolve a file path to its metadata.json path.
|
|
201
202
|
*/
|
|
202
203
|
function resolveMetaPath(filePath) {
|
|
203
|
-
if (filePath.endsWith('.metadata.json')) {
|
|
204
|
+
if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
|
|
204
205
|
return filePath;
|
|
205
206
|
}
|
|
206
207
|
const dir = dirname(filePath);
|
|
@@ -544,13 +545,13 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
|
|
|
544
545
|
// Calculate paths
|
|
545
546
|
const targetDir = resolveBinPath(targetBinId, structure);
|
|
546
547
|
const sourceDir = dirname(metaPath);
|
|
547
|
-
const contentFileName = sourceFile.endsWith('.metadata.json')
|
|
548
|
+
const contentFileName = (isMetadataFile(basename(sourceFile)) || sourceFile.endsWith('.metadata.json'))
|
|
548
549
|
? null
|
|
549
550
|
: basename(sourceFile);
|
|
550
551
|
const metaFileName = basename(metaPath);
|
|
551
552
|
|
|
552
553
|
// Determine display name
|
|
553
|
-
const displayName = basename(metaPath, '.metadata.json');
|
|
554
|
+
const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
554
555
|
const targetBin = structure[targetBinId];
|
|
555
556
|
const targetBinName = targetBin ? targetBin.name : String(targetBinId);
|
|
556
557
|
const targetBinFullPath = targetBin ? `${BINS_DIR}/${targetBin.fullPath}` : targetDir;
|
|
@@ -672,6 +673,18 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
|
|
|
672
673
|
}
|
|
673
674
|
}
|
|
674
675
|
|
|
676
|
+
if (!options.dryRun) {
|
|
677
|
+
// Update deploy config: remove old entry (by UID), re-insert with new path + correct key
|
|
678
|
+
await removeDeployEntry(uid);
|
|
679
|
+
if (newContentPath) {
|
|
680
|
+
const col = (meta._contentColumns || [])[0] || 'Content';
|
|
681
|
+
await upsertDeployEntry(newContentPath, uid, entity, col);
|
|
682
|
+
} else if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
683
|
+
const movedMediaPath = join(targetDir, String(meta._mediaFile).substring(1));
|
|
684
|
+
await upsertDeployEntry(movedMediaPath, uid, entity, 'File');
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
675
688
|
if (options.dryRun) {
|
|
676
689
|
log.info(`[DRY RUN] Would move "${displayName}" to "${targetBinName}" (${targetBinFullPath})`);
|
|
677
690
|
} else {
|
package/src/commands/push.js
CHANGED
|
@@ -12,16 +12,26 @@ import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, cl
|
|
|
12
12
|
import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
|
|
13
13
|
import { resolveTransactionKey } from '../lib/transaction-key.js';
|
|
14
14
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
15
|
-
import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, findMetadataForCompanion } from '../lib/filenames.js';
|
|
15
|
+
import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
|
|
16
16
|
import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
|
|
17
17
|
import { loadIgnore } from '../lib/ignore.js';
|
|
18
18
|
import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
|
|
19
19
|
import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '../lib/structure.js';
|
|
20
|
-
import { ensureTrashIcon } from '../lib/
|
|
20
|
+
import { ensureTrashIcon, setFileTag } from '../lib/tagging.js';
|
|
21
21
|
import { checkToeStepping } from '../lib/toe-stepping.js';
|
|
22
22
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
23
23
|
import { findUnaddedFiles, detectBinFile, submitAdd } from './add.js';
|
|
24
24
|
|
|
25
|
+
function _getMetaCompanionPaths(meta, metaPath) {
|
|
26
|
+
const dir = dirname(metaPath);
|
|
27
|
+
const paths = [];
|
|
28
|
+
for (const col of (meta._contentColumns || [])) {
|
|
29
|
+
const ref = meta[col];
|
|
30
|
+
if (ref && String(ref).startsWith('@')) paths.push(join(dir, String(ref).substring(1)));
|
|
31
|
+
}
|
|
32
|
+
return paths;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
/**
|
|
26
36
|
* Resolve an @reference file path to an absolute filesystem path.
|
|
27
37
|
* "@filename.ext" → relative to the metadata file's directory (existing behaviour)
|
|
@@ -306,13 +316,20 @@ async function moveWillDeleteToTrash(entry) {
|
|
|
306
316
|
async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
|
|
307
317
|
// Find the metadata file
|
|
308
318
|
let metaPath;
|
|
309
|
-
if (filePath.endsWith('.metadata.json')) {
|
|
319
|
+
if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
|
|
310
320
|
// User passed the metadata file directly — use it as-is
|
|
311
321
|
metaPath = filePath;
|
|
312
322
|
} else {
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
323
|
+
// Try findMetadataForCompanion first (handles both new and legacy formats)
|
|
324
|
+
const found = await findMetadataForCompanion(filePath);
|
|
325
|
+
if (found) {
|
|
326
|
+
metaPath = found;
|
|
327
|
+
} else {
|
|
328
|
+
// Fallback: old convention
|
|
329
|
+
const dir = dirname(filePath);
|
|
330
|
+
const base = basename(filePath, extname(filePath));
|
|
331
|
+
metaPath = join(dir, `${base}.metadata.json`);
|
|
332
|
+
}
|
|
316
333
|
}
|
|
317
334
|
|
|
318
335
|
let meta;
|
|
@@ -717,7 +734,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
717
734
|
const totalRecords = toPush.length + outputCompoundFiles.length + binPushItems.length;
|
|
718
735
|
if (!options.ticket && totalRecords > 0) {
|
|
719
736
|
const recordSummary = [
|
|
720
|
-
...toPush.map(r => basename(r.metaPath, '.metadata.json')),
|
|
737
|
+
...toPush.map(r => { const p = parseMetaFilename(basename(r.metaPath)); return p ? p.naturalBase : basename(r.metaPath, '.metadata.json'); }),
|
|
721
738
|
...outputCompoundFiles.map(r => basename(r.metaPath, '.json')),
|
|
722
739
|
...binPushItems.map(r => `bin:${r.meta.Name}`),
|
|
723
740
|
].join(', ');
|
|
@@ -861,6 +878,13 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
861
878
|
await updateBaselineAfterPush(baseline, successfulPushes);
|
|
862
879
|
}
|
|
863
880
|
|
|
881
|
+
// Re-tag successfully pushed files as Synced (best-effort)
|
|
882
|
+
for (const { meta, metaPath } of successfulPushes) {
|
|
883
|
+
for (const filePath of _getMetaCompanionPaths(meta, metaPath)) {
|
|
884
|
+
setFileTag(filePath, 'synced').catch(() => {});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
864
888
|
log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
|
|
865
889
|
}
|
|
866
890
|
|
|
@@ -1221,7 +1245,8 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
1221
1245
|
}
|
|
1222
1246
|
|
|
1223
1247
|
const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)${isMediaUpload ? ' + media file' : ''}` : `${dataExprs.length} field(s)`;
|
|
1224
|
-
|
|
1248
|
+
const pushDisplayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
1249
|
+
log.info(`Pushing ${pushDisplayName} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
|
|
1225
1250
|
|
|
1226
1251
|
// Apply stored ticket if no --ticket flag
|
|
1227
1252
|
const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
|
|
@@ -1449,7 +1474,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
|
|
|
1449
1474
|
if (ENTITY_DIR_NAMES.has(entity)) return;
|
|
1450
1475
|
|
|
1451
1476
|
const metaDir = dirname(metaPath);
|
|
1452
|
-
const metaBase = basename(metaPath, '.metadata.json');
|
|
1477
|
+
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
1453
1478
|
|
|
1454
1479
|
// Find the content file referenced by @filename
|
|
1455
1480
|
const contentCols = meta._contentColumns || [];
|
package/src/commands/rm.js
CHANGED
|
@@ -5,9 +5,10 @@ import { log } from '../lib/logger.js';
|
|
|
5
5
|
import { formatError } from '../lib/formatter.js';
|
|
6
6
|
import { addDeleteEntry, removeAppJsonReference } from '../lib/config.js';
|
|
7
7
|
import { findMetadataFiles } from '../lib/diff.js';
|
|
8
|
-
import { findMetadataForCompanion } from '../lib/filenames.js';
|
|
8
|
+
import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
|
|
9
9
|
import { loadStructureFile, findBinByPath, findChildBins, BINS_DIR } from '../lib/structure.js';
|
|
10
10
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
11
|
+
import { removeDeployEntry } from '../lib/deploy-config.js';
|
|
11
12
|
|
|
12
13
|
export const rmCommand = new Command('rm')
|
|
13
14
|
.description('Remove a file or directory locally and stage server deletions for the next dbo push')
|
|
@@ -42,7 +43,7 @@ export const rmCommand = new Command('rm')
|
|
|
42
43
|
* Resolve a file path to its metadata.json path.
|
|
43
44
|
*/
|
|
44
45
|
function resolveMetaPath(filePath) {
|
|
45
|
-
if (filePath.endsWith('.metadata.json')) {
|
|
46
|
+
if (isMetadataFile(basename(filePath)) || filePath.endsWith('.metadata.json')) {
|
|
46
47
|
return filePath;
|
|
47
48
|
}
|
|
48
49
|
const dir = dirname(filePath);
|
|
@@ -101,7 +102,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
|
|
|
101
102
|
localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
const displayName = basename(metaPath, '.metadata.json');
|
|
105
|
+
const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
105
106
|
|
|
106
107
|
// Prompt if needed
|
|
107
108
|
if (!skipPrompt && !options.force) {
|
|
@@ -121,6 +122,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
|
|
|
121
122
|
// Stage deletion (include metaPath for Trash workflow in push.js)
|
|
122
123
|
const expression = `RowID:del${rowId};entity:${entity}=true`;
|
|
123
124
|
await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
|
|
125
|
+
await removeDeployEntry(uid);
|
|
124
126
|
log.success(` Staged: ${displayName} → ${expression}`);
|
|
125
127
|
|
|
126
128
|
// Remove from app.json
|
|
@@ -205,7 +207,7 @@ async function rmFile(filePath, options) {
|
|
|
205
207
|
localFiles.push(join(metaDir, String(meta._mediaFile).substring(1)));
|
|
206
208
|
}
|
|
207
209
|
|
|
208
|
-
const displayName = basename(metaPath, '.metadata.json');
|
|
210
|
+
const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
209
211
|
log.info(`Removing "${displayName}" (${entity}:${uid || rowId})`);
|
|
210
212
|
for (const f of localFiles) {
|
|
211
213
|
log.dim(` ${f}`);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { tagProjectFiles } from '../lib/tagging.js';
|
|
4
|
+
import { loadTagConfig, saveTagConfig } from '../lib/config.js';
|
|
5
|
+
import { loadIgnore } from '../lib/ignore.js';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
export const tagCommand = new Command('tag')
|
|
9
|
+
.description('Apply sync-status tags (Finder color tags / gio emblems) to project files')
|
|
10
|
+
.argument('[path]', 'File or directory to tag (defaults to entire project)')
|
|
11
|
+
.option('--clear', 'Remove all dbo:* tags from companion files')
|
|
12
|
+
.option('--status', 'Show counts of synced / modified / untracked / trashed files')
|
|
13
|
+
.option('--enable', 'Enable automatic tagging after clone/pull/push')
|
|
14
|
+
.option('--disable', 'Disable automatic tagging after clone/pull/push')
|
|
15
|
+
.option('--verbose', 'Log each file with its status')
|
|
16
|
+
.action(async (pathArg, options) => {
|
|
17
|
+
if (options.enable) {
|
|
18
|
+
await saveTagConfig(true);
|
|
19
|
+
console.log(chalk.green('✔ Automatic file tagging enabled'));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (options.disable) {
|
|
23
|
+
await saveTagConfig(false);
|
|
24
|
+
console.log(chalk.yellow('Automatic file tagging disabled'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Verify project is initialized
|
|
29
|
+
const ig = await loadIgnore().catch(() => null);
|
|
30
|
+
if (!ig) {
|
|
31
|
+
console.error(chalk.red('Not a dbo project'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dir = pathArg ? join(process.cwd(), pathArg) : process.cwd();
|
|
36
|
+
|
|
37
|
+
if (options.clear) {
|
|
38
|
+
await tagProjectFiles({ clearAll: true, dir, verbose: options.verbose });
|
|
39
|
+
console.log(chalk.green('✔ All dbo:* tags cleared'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const counts = await tagProjectFiles({ verbose: options.verbose, dir });
|
|
44
|
+
|
|
45
|
+
if (counts === null) {
|
|
46
|
+
const { tagFiles } = await loadTagConfig();
|
|
47
|
+
if (!tagFiles) {
|
|
48
|
+
console.log(chalk.yellow('File tagging is disabled. Run `dbo tag --enable` to enable.'));
|
|
49
|
+
} else {
|
|
50
|
+
console.log(chalk.dim('File tagging is not supported on this platform.'));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (options.status || options.verbose) {
|
|
56
|
+
console.log(
|
|
57
|
+
`${chalk.green(counts.synced)} synced, ` +
|
|
58
|
+
`${chalk.blue(counts.modified)} modified, ` +
|
|
59
|
+
`${chalk.yellow(counts.untracked)} untracked, ` +
|
|
60
|
+
`${chalk.red(counts.trashed)} trashed`
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
console.log(chalk.green(`✔ Tags applied (${counts.synced} synced, ${counts.modified} modified, ${counts.untracked} untracked)`));
|
|
64
|
+
}
|
|
65
|
+
});
|
package/src/lib/config.js
CHANGED
|
@@ -936,3 +936,31 @@ export async function loadScriptsLocal() {
|
|
|
936
936
|
throw new SyntaxError(`Invalid JSON in .dbo/scripts.local.json: ${err.message}`);
|
|
937
937
|
}
|
|
938
938
|
}
|
|
939
|
+
|
|
940
|
+
// ─── Tag Config ───────────────────────────────────────────────────────────────
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Load the tagFiles setting from config.json.
|
|
944
|
+
* @returns {Promise<{tagFiles: boolean}>} defaults to true
|
|
945
|
+
*/
|
|
946
|
+
export async function loadTagConfig() {
|
|
947
|
+
try {
|
|
948
|
+
const raw = await readFile(join(process.cwd(), DBO_DIR, CONFIG_FILE), 'utf8');
|
|
949
|
+
const config = JSON.parse(raw);
|
|
950
|
+
return { tagFiles: config.tagFiles !== false };
|
|
951
|
+
} catch {
|
|
952
|
+
return { tagFiles: true };
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Enable or disable automatic file tagging by writing to config.json.
|
|
958
|
+
* @param {boolean} enabled
|
|
959
|
+
*/
|
|
960
|
+
export async function saveTagConfig(enabled) {
|
|
961
|
+
const configPath = join(process.cwd(), DBO_DIR, CONFIG_FILE);
|
|
962
|
+
let config = {};
|
|
963
|
+
try { config = JSON.parse(await readFile(configPath, 'utf8')); } catch {}
|
|
964
|
+
config.tagFiles = enabled;
|
|
965
|
+
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
966
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join, relative, extname, basename } from 'path';
|
|
3
|
+
import { log } from './logger.js';
|
|
4
|
+
|
|
5
|
+
const DEPLOY_CONFIG_FILE = '.dbo/deploy_config.json';
|
|
6
|
+
|
|
7
|
+
function deployConfigPath() {
|
|
8
|
+
return join(process.cwd(), DEPLOY_CONFIG_FILE);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sortedKeys(obj) {
|
|
12
|
+
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load .dbo/deploy_config.json. Returns { deployments: {} } if missing.
|
|
17
|
+
* Throws on malformed JSON (do not silently recreate — would lose existing entries).
|
|
18
|
+
*/
|
|
19
|
+
export async function loadDeployConfig() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(deployConfigPath(), 'utf8');
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err.code === 'ENOENT') return { deployments: {} };
|
|
25
|
+
throw new Error(`Failed to parse ${DEPLOY_CONFIG_FILE}: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Write .dbo/deploy_config.json with alphabetically sorted deployment keys.
|
|
31
|
+
*/
|
|
32
|
+
export async function saveDeployConfig(config) {
|
|
33
|
+
await mkdir(join(process.cwd(), '.dbo'), { recursive: true });
|
|
34
|
+
const sorted = { ...config, deployments: sortedKeys(config.deployments || {}) };
|
|
35
|
+
await writeFile(deployConfigPath(), JSON.stringify(sorted, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive the <ext>:<basename> deploy key from a relative file path.
|
|
40
|
+
* Finds the shortest key not already occupied in existingDeployments.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} relPath - e.g. "lib/bins/app/assets/css/colors.css"
|
|
43
|
+
* @param {Object} existingDeployments - current deployments object (to avoid collisions)
|
|
44
|
+
* @returns {string} - e.g. "css:colors"
|
|
45
|
+
*/
|
|
46
|
+
export function buildDeployKey(relPath, existingDeployments = {}) {
|
|
47
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
48
|
+
const parts = normalized.split('/');
|
|
49
|
+
const filename = parts[parts.length - 1];
|
|
50
|
+
const ext = extname(filename).slice(1).toLowerCase(); // '' if no dot
|
|
51
|
+
const base = ext ? basename(filename, `.${ext}`) : filename;
|
|
52
|
+
const prefix = ext || 'file';
|
|
53
|
+
|
|
54
|
+
// Try simplest key first, then progressively add parent segments for disambiguation
|
|
55
|
+
const dirParts = parts.slice(0, -1);
|
|
56
|
+
const candidates = [`${prefix}:${base}`];
|
|
57
|
+
for (let depth = 1; depth <= dirParts.length; depth++) {
|
|
58
|
+
const suffix = dirParts.slice(-depth).join('/');
|
|
59
|
+
candidates.push(`${prefix}:${suffix}/${base}`);
|
|
60
|
+
}
|
|
61
|
+
candidates.push(`${prefix}:${normalized}`); // absolute fallback
|
|
62
|
+
|
|
63
|
+
for (const key of candidates) {
|
|
64
|
+
if (!existingDeployments[key]) return key;
|
|
65
|
+
}
|
|
66
|
+
return `${prefix}:${normalized}`; // always unique
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Insert or update a deploy config entry for a companion file.
|
|
71
|
+
*
|
|
72
|
+
* Behaviour:
|
|
73
|
+
* - If an entry with the same UID already exists → update file, entity, column in place
|
|
74
|
+
* (keeps the existing key, avoids duplicate keys on re-clone)
|
|
75
|
+
* - If a new key collision exists with a DIFFERENT UID → log a warning, skip
|
|
76
|
+
* - Otherwise → create new entry with the shortest unique key
|
|
77
|
+
*
|
|
78
|
+
* @param {string} companionPath - Absolute path to the companion file
|
|
79
|
+
* @param {string} uid - Record UID (from metadata)
|
|
80
|
+
* @param {string} entity - Entity type (e.g. 'content', 'extension', 'media')
|
|
81
|
+
* @param {string} [column] - Column name (e.g. 'Content', 'CSS')
|
|
82
|
+
*/
|
|
83
|
+
export async function upsertDeployEntry(companionPath, uid, entity, column) {
|
|
84
|
+
if (!uid || !companionPath) return;
|
|
85
|
+
const config = await loadDeployConfig();
|
|
86
|
+
const { deployments } = config;
|
|
87
|
+
|
|
88
|
+
const relPath = relative(process.cwd(), companionPath).replace(/\\/g, '/');
|
|
89
|
+
|
|
90
|
+
// Find if this UID is already tracked under any key
|
|
91
|
+
const existingKey = Object.keys(deployments).find(k => deployments[k].uid === uid);
|
|
92
|
+
|
|
93
|
+
if (existingKey) {
|
|
94
|
+
// Update path + metadata in place — keeps existing key, avoids duplicates on re-clone
|
|
95
|
+
deployments[existingKey] = {
|
|
96
|
+
...deployments[existingKey],
|
|
97
|
+
file: relPath,
|
|
98
|
+
entity,
|
|
99
|
+
...(column ? { column } : {}),
|
|
100
|
+
};
|
|
101
|
+
config.deployments = sortedKeys(deployments);
|
|
102
|
+
await saveDeployConfig(config);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// New entry — build unique key avoiding all occupied keys
|
|
107
|
+
const key = buildDeployKey(relPath, deployments);
|
|
108
|
+
|
|
109
|
+
if (deployments[key]) {
|
|
110
|
+
// buildDeployKey returned an occupied key (should not happen — means all path segments collide)
|
|
111
|
+
log.warn(`Deploy config: skipping auto-registration of "${relPath}" — key "${key}" already exists for a different record. Add manually to ${DEPLOY_CONFIG_FILE}.`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const entry = { uid, file: relPath, entity };
|
|
116
|
+
if (column) entry.column = column;
|
|
117
|
+
deployments[key] = entry;
|
|
118
|
+
config.deployments = sortedKeys(deployments);
|
|
119
|
+
await saveDeployConfig(config);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove the deploy config entry matching a given UID.
|
|
124
|
+
* No-op if no entry with that UID exists.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} uid - Record UID to remove
|
|
127
|
+
*/
|
|
128
|
+
export async function removeDeployEntry(uid) {
|
|
129
|
+
if (!uid) return;
|
|
130
|
+
const config = await loadDeployConfig();
|
|
131
|
+
const { deployments } = config;
|
|
132
|
+
const key = Object.keys(deployments).find(k => deployments[k].uid === uid);
|
|
133
|
+
if (!key) return;
|
|
134
|
+
delete deployments[key];
|
|
135
|
+
config.deployments = sortedKeys(deployments);
|
|
136
|
+
await saveDeployConfig(config);
|
|
137
|
+
}
|
package/src/lib/diff.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadIgnore } from './ignore.js';
|
|
|
5
5
|
import { parseServerDate, setFileTimestamps } from './timestamps.js';
|
|
6
6
|
import { loadConfig, loadUserInfo, loadAppJsonBaseline } from './config.js';
|
|
7
7
|
import { findBaselineEntry } from './delta.js';
|
|
8
|
+
import { isMetadataFile, parseMetaFilename } from './filenames.js';
|
|
8
9
|
import { log } from './logger.js';
|
|
9
10
|
|
|
10
11
|
// ─── Baseline Cache ─────────────────────────────────────────────────────────
|
|
@@ -155,10 +156,10 @@ export async function findMetadataFiles(dir, ig) {
|
|
|
155
156
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
156
157
|
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
157
158
|
results.push(...await findMetadataFiles(fullPath, ig));
|
|
158
|
-
} else if (entry.name
|
|
159
|
+
} else if (isMetadataFile(entry.name) && !entry.name.startsWith('__WILL_DELETE__')) {
|
|
159
160
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
160
161
|
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
161
|
-
} else if (entry.name.endsWith('.json') && !entry.name
|
|
162
|
+
} else if (entry.name.endsWith('.json') && !isMetadataFile(entry.name) && !entry.name.includes('.CustomSQL.') && entry.name.includes('~')) {
|
|
162
163
|
// Output hierarchy root files: Name~UID.json (or legacy _output~Name~UID.json)
|
|
163
164
|
// Exclude old-format child output files — they contain a dot-prefixed
|
|
164
165
|
// child-type segment (.column~, .join~, .filter~) before .json.
|
|
@@ -495,7 +496,7 @@ export async function compareRecord(metaPath, config, serverRecordsMap) {
|
|
|
495
496
|
}
|
|
496
497
|
|
|
497
498
|
const metaDir = dirname(metaPath);
|
|
498
|
-
const metaBase = basename(metaPath, '.metadata.json');
|
|
499
|
+
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
499
500
|
const contentCols = localMeta._contentColumns || [];
|
|
500
501
|
const fieldDiffs = [];
|
|
501
502
|
|
|
@@ -799,7 +800,7 @@ export async function inlineDiffAndMerge(serverRow, metaPath, config, options =
|
|
|
799
800
|
}
|
|
800
801
|
|
|
801
802
|
const metaDir = dirname(metaPath);
|
|
802
|
-
const metaBase = basename(metaPath, '.metadata.json');
|
|
803
|
+
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
803
804
|
const contentCols = localMeta._contentColumns || [];
|
|
804
805
|
const fieldDiffs = [];
|
|
805
806
|
|