@dboio/cli 0.19.7 → 0.20.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 +18 -12
- package/bin/dbo.js +5 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +39 -12
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +18 -12
- package/plugins/claude/dbo/skills/cookbook/SKILL.md +13 -3
- package/plugins/claude/dbo/skills/white-paper/SKILL.md +49 -8
- package/plugins/claude/dbo/skills/white-paper/references/api-reference.md +1 -1
- package/plugins/claude/track/.claude-plugin/plugin.json +1 -1
- package/src/commands/adopt.js +4 -3
- package/src/commands/clone.js +245 -55
- package/src/commands/init.js +2 -2
- package/src/commands/input.js +2 -2
- package/src/commands/login.js +3 -3
- package/src/commands/push.js +78 -18
- package/src/commands/status.js +15 -7
- package/src/lib/config.js +91 -11
- package/src/lib/filenames.js +54 -66
- package/src/lib/ignore.js +3 -0
- package/src/lib/insert.js +29 -45
- package/src/lib/structure.js +23 -8
- package/src/lib/ticketing.js +9 -8
- package/src/migrations/008-metadata-uid-in-suffix.js +4 -2
- package/src/migrations/009-fix-media-collision-metadata-names.js +9 -3
- package/src/migrations/013-remove-uid-from-meta-filenames.js +117 -0
- package/src/migrations/014-entity-dir-to-data-source.js +68 -0
package/src/commands/push.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, stat, writeFile, rename as fsRename, mkdir, access, utimes } from 'fs/promises';
|
|
3
|
-
import { join, dirname, basename, extname, relative } from 'path';
|
|
2
|
+
import { readFile, stat, writeFile, rename as fsRename, mkdir, access, utimes, readdir } from 'fs/promises';
|
|
3
|
+
import { join, dirname, basename, extname, relative, sep } from 'path';
|
|
4
4
|
import { DboClient } from '../lib/client.js';
|
|
5
5
|
import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
|
|
6
6
|
import { formatResponse, formatError } from '../lib/formatter.js';
|
|
@@ -12,7 +12,7 @@ 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, parseServerDate } from '../lib/timestamps.js';
|
|
15
|
-
import { stripUidFromFilename, renameToUidConvention,
|
|
15
|
+
import { stripUidFromFilename, renameToUidConvention, 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, detectEntityChildrenChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
|
|
@@ -480,7 +480,7 @@ async function ensureRootContentFiles() {
|
|
|
480
480
|
const ig = await loadIgnore();
|
|
481
481
|
const allMeta = await findMetadataFiles(process.cwd(), ig);
|
|
482
482
|
|
|
483
|
-
// Build a set of already-tracked filenames from existing metadata.
|
|
483
|
+
// Build a set of already-tracked filenames from existing metadata (lowercase for case-insensitive match).
|
|
484
484
|
// Also strip stale fields (e.g. Descriptor) from content-entity root file metadata.
|
|
485
485
|
const tracked = new Set();
|
|
486
486
|
for (const metaPath of allMeta) {
|
|
@@ -493,8 +493,8 @@ async function ensureRootContentFiles() {
|
|
|
493
493
|
const refName = content.startsWith('@/') ? content.slice(2)
|
|
494
494
|
: content.startsWith('@') ? content.slice(1)
|
|
495
495
|
: null;
|
|
496
|
-
if (refName) tracked.add(refName);
|
|
497
|
-
if (path) tracked.add(path.replace(/^\//, ''));
|
|
496
|
+
if (refName) tracked.add(refName.toLowerCase());
|
|
497
|
+
if (path) tracked.add(path.replace(/^\//, '').toLowerCase());
|
|
498
498
|
|
|
499
499
|
// Clean up stale Descriptor field: content entities never have Descriptor.
|
|
500
500
|
// This fixes metadata written by an earlier buggy version of the tool.
|
|
@@ -515,10 +515,11 @@ async function ensureRootContentFiles() {
|
|
|
515
515
|
for (const filename of rootFiles) {
|
|
516
516
|
// Skip if file doesn't exist at project root
|
|
517
517
|
try { await access(join(process.cwd(), filename)); } catch { continue; }
|
|
518
|
-
// Skip if already tracked by existing metadata
|
|
519
|
-
if (tracked.has(filename)) continue;
|
|
518
|
+
// Skip if already tracked by existing metadata (case-insensitive)
|
|
519
|
+
if (tracked.has(filename.toLowerCase())) continue;
|
|
520
520
|
|
|
521
|
-
const
|
|
521
|
+
const tmplKey = Object.keys(ROOT_FILE_TEMPLATES).find(k => k.toLowerCase() === filename.toLowerCase());
|
|
522
|
+
const tmpl = (tmplKey ? ROOT_FILE_TEMPLATES[tmplKey] : null) || {
|
|
522
523
|
_entity: 'content',
|
|
523
524
|
_companionReferenceColumns: ['Content'],
|
|
524
525
|
Extension: (filename.includes('.') ? filename.split('.').pop().toUpperCase() : 'TXT'),
|
|
@@ -544,6 +545,62 @@ async function ensureRootContentFiles() {
|
|
|
544
545
|
}
|
|
545
546
|
}
|
|
546
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Collect all non-metadata file absolute paths from a directory (recursive).
|
|
550
|
+
*/
|
|
551
|
+
async function collectCompanionFiles(dirPath, ig) {
|
|
552
|
+
const results = [];
|
|
553
|
+
let entries;
|
|
554
|
+
try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return results; }
|
|
555
|
+
for (const entry of entries) {
|
|
556
|
+
const fullPath = join(process.cwd(), dirPath, entry.name);
|
|
557
|
+
if (entry.isDirectory()) {
|
|
558
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
559
|
+
if (!ig.ignores(relPath + '/') && !ig.ignores(relPath)) {
|
|
560
|
+
results.push(...await collectCompanionFiles(join(dirPath, entry.name), ig));
|
|
561
|
+
}
|
|
562
|
+
} else if (!isMetadataFile(entry.name)) {
|
|
563
|
+
results.push(fullPath);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return results;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Find metadata files project-wide that reference companion files in a given set via @reference.
|
|
571
|
+
* Used when pushing a directory like docs/ whose metadata lives elsewhere (e.g. lib/extension/Documentation/).
|
|
572
|
+
*/
|
|
573
|
+
async function findCrossDirectoryMetadata(dirPath, ig) {
|
|
574
|
+
const companionFiles = await collectCompanionFiles(dirPath, ig);
|
|
575
|
+
if (companionFiles.length === 0) return [];
|
|
576
|
+
|
|
577
|
+
const companionSet = new Set(companionFiles);
|
|
578
|
+
const appDepsPrefix = join(process.cwd(), 'app_dependencies') + sep;
|
|
579
|
+
const allMetaFiles = (await findMetadataFiles(process.cwd(), ig))
|
|
580
|
+
.filter(p => !p.startsWith(appDepsPrefix));
|
|
581
|
+
const found = [];
|
|
582
|
+
|
|
583
|
+
for (const metaPath of allMetaFiles) {
|
|
584
|
+
try {
|
|
585
|
+
const candidateMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
586
|
+
const metaDir = dirname(metaPath);
|
|
587
|
+
const cols = [...(candidateMeta._companionReferenceColumns || candidateMeta._contentColumns || [])];
|
|
588
|
+
if (candidateMeta._mediaFile) cols.push('_mediaFile');
|
|
589
|
+
for (const col of cols) {
|
|
590
|
+
const ref = candidateMeta[col];
|
|
591
|
+
if (!ref || !String(ref).startsWith('@')) continue;
|
|
592
|
+
const resolved = resolveAtReference(String(ref).substring(1), metaDir);
|
|
593
|
+
if (companionSet.has(resolved)) {
|
|
594
|
+
found.push(metaPath);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} catch { /* skip unreadable */ }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return found;
|
|
602
|
+
}
|
|
603
|
+
|
|
547
604
|
/**
|
|
548
605
|
* Push all records found in a directory (recursive)
|
|
549
606
|
*/
|
|
@@ -559,6 +616,14 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
559
616
|
const ig = await loadIgnore();
|
|
560
617
|
const metaFiles = await findMetadataFiles(dirPath, ig);
|
|
561
618
|
|
|
619
|
+
// Cross-directory metadata lookup: if the target dir has no metadata files of its own
|
|
620
|
+
// (e.g. docs/ only contains .md companions, with metadata in lib/extension/Documentation/),
|
|
621
|
+
// find metadata files project-wide that reference those companions via @reference.
|
|
622
|
+
if (metaFiles.length === 0) {
|
|
623
|
+
const crossMetas = await findCrossDirectoryMetadata(dirPath, ig);
|
|
624
|
+
metaFiles.push(...crossMetas);
|
|
625
|
+
}
|
|
626
|
+
|
|
562
627
|
// ── Load scripts config early (before delta detection) ──────────────
|
|
563
628
|
// Build hooks must run BEFORE delta detection so compiled output files
|
|
564
629
|
// (SASS → CSS, Rollup → JS, etc.) are on disk when changes are detected.
|
|
@@ -1440,24 +1505,19 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
1440
1505
|
// Clean up per-record ticket on success
|
|
1441
1506
|
await clearRecordTicket(uid || id);
|
|
1442
1507
|
|
|
1443
|
-
// Post-UID
|
|
1508
|
+
// Post-insert UID write: if the record lacked a UID and the server returned one
|
|
1444
1509
|
try {
|
|
1445
1510
|
const editResults2 = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
|
|
1446
1511
|
const addResults2 = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
|
|
1447
1512
|
const allResults = [...editResults2, ...addResults2];
|
|
1448
1513
|
if (allResults.length > 0 && !meta.UID) {
|
|
1449
1514
|
const serverUID = allResults[0].UID;
|
|
1450
|
-
if (serverUID
|
|
1515
|
+
if (serverUID) {
|
|
1451
1516
|
const config2 = await loadConfig();
|
|
1452
|
-
|
|
1453
|
-
if (renameResult.newMetaPath !== metaPath) {
|
|
1454
|
-
log.success(` Renamed to ~${serverUID} convention`);
|
|
1455
|
-
// Update metaPath reference for subsequent timestamp operations
|
|
1456
|
-
// (metaPath is const, but timestamp update below re-reads from meta)
|
|
1457
|
-
}
|
|
1517
|
+
await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
|
|
1458
1518
|
}
|
|
1459
1519
|
}
|
|
1460
|
-
} catch { /* non-critical
|
|
1520
|
+
} catch { /* non-critical */ }
|
|
1461
1521
|
|
|
1462
1522
|
// Update file timestamps from server response
|
|
1463
1523
|
try {
|
package/src/commands/status.js
CHANGED
|
@@ -59,16 +59,24 @@ export const statusCommand = new Command('status')
|
|
|
59
59
|
log.plain('');
|
|
60
60
|
log.info('Claude Code Plugins:');
|
|
61
61
|
for (const [name, scope] of Object.entries(scopes)) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
let resolvedScope, location;
|
|
63
|
+
if (Array.isArray(scope)) {
|
|
64
|
+
// Registry format: [{ scope: 'user'|'project', installPath, ... }]
|
|
65
|
+
const entry = scope[0];
|
|
66
|
+
resolvedScope = entry.scope === 'user' ? 'global' : (entry.scope || 'project');
|
|
67
|
+
location = entry.installPath;
|
|
67
68
|
} else {
|
|
68
|
-
|
|
69
|
+
// Legacy format: scope is a plain string
|
|
70
|
+
resolvedScope = scope || 'project';
|
|
71
|
+
const fileName = `${name}.md`;
|
|
72
|
+
location = resolvedScope === 'global'
|
|
73
|
+
? join(homedir(), '.claude', 'commands', fileName)
|
|
74
|
+
: join(process.cwd(), '.claude', 'commands', fileName);
|
|
69
75
|
}
|
|
70
76
|
let installed = false;
|
|
71
|
-
|
|
77
|
+
if (location) {
|
|
78
|
+
try { await access(location); installed = true; } catch {}
|
|
79
|
+
}
|
|
72
80
|
const icon = installed ? '\u2713' : '\u2717';
|
|
73
81
|
const scopeLabel = resolvedScope === 'global' ? 'global' : 'project';
|
|
74
82
|
log.label(` ${name}`, `${icon} ${scopeLabel} (${installed ? location : 'not found'})`);
|
package/src/lib/config.js
CHANGED
|
@@ -527,7 +527,7 @@ export async function removeAppJsonReference(metaPath) {
|
|
|
527
527
|
// ─── config.local.json (global ~/.dbo/settings.json) ────────────────────
|
|
528
528
|
|
|
529
529
|
function configLocalPath() {
|
|
530
|
-
return join(homedir(), '.dbo', CONFIG_LOCAL_FILE);
|
|
530
|
+
return process.env.DBO_SETTINGS_PATH || join(homedir(), '.dbo', CONFIG_LOCAL_FILE);
|
|
531
531
|
}
|
|
532
532
|
|
|
533
533
|
async function ensureGlobalDboDir() {
|
|
@@ -817,8 +817,58 @@ export async function removeFromGitignore(pattern) {
|
|
|
817
817
|
log.dim(` Removed ${pattern} from .gitignore`);
|
|
818
818
|
}
|
|
819
819
|
|
|
820
|
+
/** Canonical set of gitignore entries for DBO CLI projects. */
|
|
821
|
+
export const DEFAULT_GITIGNORE_ENTRIES = [
|
|
822
|
+
'*local.',
|
|
823
|
+
'.DS_Store',
|
|
824
|
+
'*.DS_Store',
|
|
825
|
+
'*/.DS_Store',
|
|
826
|
+
'.idea*',
|
|
827
|
+
'.vscode*',
|
|
828
|
+
'*/node_modules',
|
|
829
|
+
'config.codekit3',
|
|
830
|
+
'/node_modules/',
|
|
831
|
+
'cookies.txt',
|
|
832
|
+
'*app.compiled.js',
|
|
833
|
+
'*.min.css',
|
|
834
|
+
'*.min.js',
|
|
835
|
+
'.profile',
|
|
836
|
+
'.secret',
|
|
837
|
+
'.password',
|
|
838
|
+
'.username',
|
|
839
|
+
'.cookies',
|
|
840
|
+
'.domain',
|
|
841
|
+
'.OverrideTicketID',
|
|
842
|
+
'media/*',
|
|
843
|
+
'',
|
|
844
|
+
'# DBO CLI (sensitive files)',
|
|
845
|
+
'.app/credentials.json',
|
|
846
|
+
'.app/cookies.txt',
|
|
847
|
+
'.app/ticketing.local.json',
|
|
848
|
+
'trash/',
|
|
849
|
+
'.app.json',
|
|
850
|
+
'',
|
|
851
|
+
'.app/scripts.local.json',
|
|
852
|
+
'.app/errors.log',
|
|
853
|
+
'',
|
|
854
|
+
'# DBO CLI (machine-generated baselines — regenerated by dbo pull/clone)',
|
|
855
|
+
'.app/operator.json',
|
|
856
|
+
'.app/operator.metadata.json',
|
|
857
|
+
'.app/synchronize.json',
|
|
858
|
+
'app_dependencies/',
|
|
859
|
+
'',
|
|
860
|
+
'# DBO CLI Claude Code commands (managed by dbo-cli)',
|
|
861
|
+
'.claude/plugins/dbo/',
|
|
862
|
+
'',
|
|
863
|
+
'# DBO CLI Claude Code commands (managed by dbo-cli)',
|
|
864
|
+
'.claude/plugins/track/',
|
|
865
|
+
];
|
|
866
|
+
|
|
820
867
|
/**
|
|
821
|
-
* Ensure
|
|
868
|
+
* Ensure entries are in .gitignore. Creates .gitignore if it doesn't exist.
|
|
869
|
+
* Accepts an array that may include comment lines and blank separators;
|
|
870
|
+
* only entries not already present in the file are appended, preserving
|
|
871
|
+
* the section structure (comments, blank lines) of the input list.
|
|
822
872
|
*/
|
|
823
873
|
export async function ensureGitignore(patterns) {
|
|
824
874
|
const gitignorePath = join(process.cwd(), '.gitignore');
|
|
@@ -827,18 +877,48 @@ export async function ensureGitignore(patterns) {
|
|
|
827
877
|
content = await readFile(gitignorePath, 'utf8');
|
|
828
878
|
} catch { /* no .gitignore yet */ }
|
|
829
879
|
|
|
830
|
-
const
|
|
831
|
-
if (
|
|
880
|
+
const isMissing = entry => entry.trim() !== '' && !content.includes(entry);
|
|
881
|
+
if (!patterns.some(isMissing)) return;
|
|
882
|
+
|
|
883
|
+
// Build the block to append: include missing entries with their surrounding
|
|
884
|
+
// structure (comments, blank separators), omitting blanks that end up adjacent
|
|
885
|
+
// to already-present entries.
|
|
886
|
+
const outputLines = [];
|
|
887
|
+
let prevWasContent = false;
|
|
888
|
+
|
|
889
|
+
for (const entry of patterns) {
|
|
890
|
+
if (entry.trim() === '') {
|
|
891
|
+
if (prevWasContent) outputLines.push('');
|
|
892
|
+
prevWasContent = false;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (isMissing(entry)) {
|
|
896
|
+
outputLines.push(entry);
|
|
897
|
+
prevWasContent = true;
|
|
898
|
+
} else {
|
|
899
|
+
// Entry already exists — remove any optimistically-added trailing blank.
|
|
900
|
+
if (!prevWasContent && outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') {
|
|
901
|
+
outputLines.pop();
|
|
902
|
+
}
|
|
903
|
+
prevWasContent = false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Strip trailing blank lines.
|
|
908
|
+
while (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') {
|
|
909
|
+
outputLines.pop();
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (outputLines.length === 0) return;
|
|
832
913
|
|
|
833
914
|
const isNew = content.length === 0;
|
|
834
915
|
const needsNewline = !isNew && !content.endsWith('\n');
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const addition = `${section}${header}${toAdd.join('\n')}\n`;
|
|
916
|
+
const prefix = needsNewline ? '\n\n' : (isNew ? '' : '\n');
|
|
917
|
+
|
|
918
|
+
await writeFile(gitignorePath, content + prefix + outputLines.join('\n') + '\n');
|
|
839
919
|
|
|
840
|
-
|
|
841
|
-
for (const p of
|
|
920
|
+
const added = outputLines.filter(l => l.trim() !== '' && !l.startsWith('#'));
|
|
921
|
+
for (const p of added) log.dim(` Added ${p} to .gitignore`);
|
|
842
922
|
}
|
|
843
923
|
|
|
844
924
|
// ─── Baseline (.app/<shortName>.json) ─────────────────────────────────────
|
|
@@ -1086,7 +1166,7 @@ export async function loadScriptsLocal() {
|
|
|
1086
1166
|
|
|
1087
1167
|
// ─── Root Content Files ───────────────────────────────────────────────────────
|
|
1088
1168
|
|
|
1089
|
-
const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json'];
|
|
1169
|
+
const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json', 'package.json', '.dboignore', '.gitignore'];
|
|
1090
1170
|
|
|
1091
1171
|
/**
|
|
1092
1172
|
* Load rootContentFiles from .app/config.json.
|
package/src/lib/filenames.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Filename convention helpers for the metadata
|
|
3
|
-
* Metadata files: name.metadata
|
|
4
|
-
* Companion files: natural names
|
|
2
|
+
* Filename convention helpers for the metadata convention.
|
|
3
|
+
* Metadata files: name.metadata.json (UID is stored inside the JSON, not in the filename)
|
|
4
|
+
* Companion files: natural names.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { rename, unlink, writeFile, readFile, readdir } from 'fs/promises';
|
|
@@ -15,6 +15,8 @@ import { join, dirname, basename, extname } from 'path';
|
|
|
15
15
|
* @param {string} uid - Server-assigned UID
|
|
16
16
|
* @param {string} [ext] - File extension WITHOUT leading dot (e.g. 'css', 'png', '')
|
|
17
17
|
* @returns {string} - e.g. "colors~abc123.css" or "abc123.css" or "colors~abc123"
|
|
18
|
+
* @deprecated Use buildMetaFilename() for metadata files. This function is retained
|
|
19
|
+
* only for legacy detection/migration code.
|
|
18
20
|
*/
|
|
19
21
|
export function buildUidFilename(name, uid, ext = '') {
|
|
20
22
|
const base = (name === uid) ? uid : `${name}~${uid}`;
|
|
@@ -100,6 +102,7 @@ export function stripUidFromFilename(localName, uid) {
|
|
|
100
102
|
|
|
101
103
|
/**
|
|
102
104
|
* Check whether a filename already contains ~<uid>.
|
|
105
|
+
* @deprecated Only used by legacy migration code.
|
|
103
106
|
*/
|
|
104
107
|
export function hasUidInFilename(filename, uid) {
|
|
105
108
|
return typeof filename === 'string' && typeof uid === 'string'
|
|
@@ -114,25 +117,28 @@ export function hasUidInFilename(filename, uid) {
|
|
|
114
117
|
* Returns { name, uid } or null.
|
|
115
118
|
*/
|
|
116
119
|
export function detectLegacyDotUid(filename) {
|
|
117
|
-
const match = filename.match(/^(.+)\.([a-z0-
|
|
120
|
+
const match = filename.match(/^(.+)\.([a-z0-9_]{10,})\.metadata\.json$/);
|
|
118
121
|
return match ? { name: match[1], uid: match[2] } : null;
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
/**
|
|
122
|
-
* Build
|
|
123
|
-
* Format: <naturalBase>.metadata
|
|
125
|
+
* Build a metadata filename for a record.
|
|
126
|
+
* Format: <naturalBase>.metadata.json
|
|
127
|
+
* The UID is stored inside the JSON, not in the filename.
|
|
124
128
|
*
|
|
125
|
-
* For content records:
|
|
126
|
-
* For media records:
|
|
127
|
-
* For output records:
|
|
129
|
+
* For content records: naturalBase = "colors" → "colors.metadata.json"
|
|
130
|
+
* For media records: naturalBase = "logo.png" → "logo.png.metadata.json"
|
|
131
|
+
* For output records: naturalBase = "Sales" → "Sales.metadata.json"
|
|
132
|
+
*
|
|
133
|
+
* Collision avoidance (when two records share a name) is handled by the caller
|
|
134
|
+
* via numbered suffixes: "colors-1.metadata.json", "colors-2.metadata.json", etc.
|
|
128
135
|
*
|
|
129
136
|
* @param {string} naturalBase - Name without any ~uid (may include media extension)
|
|
130
|
-
* @param {string} uid - Server-assigned UID
|
|
131
137
|
* @returns {string}
|
|
132
138
|
*/
|
|
133
|
-
export function buildMetaFilename(naturalBase
|
|
139
|
+
export function buildMetaFilename(naturalBase) {
|
|
134
140
|
// Guard: strip any trailing .metadata suffix(es) and ~uid fragments from naturalBase
|
|
135
|
-
// to prevent double-metadata filenames (e.g., "app.metadata.metadata
|
|
141
|
+
// to prevent double-metadata filenames (e.g., "app.metadata.metadata.json")
|
|
136
142
|
let base = naturalBase;
|
|
137
143
|
const metaParsed = parseMetaFilename(base + '.json');
|
|
138
144
|
if (metaParsed) {
|
|
@@ -142,34 +148,43 @@ export function buildMetaFilename(naturalBase, uid) {
|
|
|
142
148
|
while (base.endsWith('.metadata')) {
|
|
143
149
|
base = base.substring(0, base.length - 9);
|
|
144
150
|
}
|
|
145
|
-
return `${base}.metadata
|
|
151
|
+
return `${base}.metadata.json`;
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
/**
|
|
149
155
|
* Test whether a filename is a metadata file (new or legacy format).
|
|
150
156
|
*
|
|
151
|
-
* New format: name.metadata
|
|
152
|
-
* Legacy format: name~uid.
|
|
157
|
+
* New format: name.metadata.json (UID in JSON content, not filename)
|
|
158
|
+
* Legacy format: name.metadata~uid.json (also accepted during migration)
|
|
153
159
|
*
|
|
154
160
|
* @param {string} filename
|
|
155
161
|
* @returns {boolean}
|
|
156
162
|
*/
|
|
157
163
|
export function isMetadataFile(filename) {
|
|
158
|
-
return
|
|
159
|
-
||
|
|
160
|
-
|| /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename);
|
|
164
|
+
return filename.endsWith('.metadata.json') // new format + legacy pre-UID temp files
|
|
165
|
+
|| /\.metadata~[a-z0-9_]+\.json$/i.test(filename) // legacy tilde-uid suffix format
|
|
166
|
+
|| /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename); // legacy dot-separated: name.uid.metadata.json
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
/**
|
|
164
|
-
* Parse a
|
|
165
|
-
*
|
|
170
|
+
* Parse a metadata filename into its components.
|
|
171
|
+
*
|
|
172
|
+
* New format: name.metadata.json → { naturalBase: "name" }
|
|
173
|
+
* Legacy format: name.metadata~uid.json → { naturalBase: "name", uid: "uid" }
|
|
166
174
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
175
|
+
* Returns null for non-metadata filenames.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} filename
|
|
178
|
+
* @returns {{ naturalBase: string, uid?: string } | null}
|
|
169
179
|
*/
|
|
170
180
|
export function parseMetaFilename(filename) {
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
// Legacy format with uid in suffix (migration target for 008, source for 013)
|
|
182
|
+
const legacy = filename.match(/^(.+)\.metadata~([a-z0-9_]+)\.json$/i);
|
|
183
|
+
if (legacy) return { naturalBase: legacy[1], uid: legacy[2] };
|
|
184
|
+
|
|
185
|
+
// New format: name.metadata.json
|
|
186
|
+
const m = filename.match(/^(.+)\.metadata\.json$/i);
|
|
187
|
+
return m ? { naturalBase: m[1] } : null;
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
/**
|
|
@@ -181,11 +196,11 @@ export function parseMetaFilename(filename) {
|
|
|
181
196
|
*/
|
|
182
197
|
export function detectLegacyTildeMetadata(filename) {
|
|
183
198
|
// Case 1: name~uid.metadata.json (content/entity metadata)
|
|
184
|
-
const m1 = filename.match(/^(.+)~([a-z0-
|
|
199
|
+
const m1 = filename.match(/^(.+)~([a-z0-9_]{10,})\.metadata\.json$/);
|
|
185
200
|
if (m1) return { naturalBase: m1[1], uid: m1[2] };
|
|
186
201
|
|
|
187
202
|
// Case 2: name~uid.ext.metadata.json (media metadata)
|
|
188
|
-
const m2 = filename.match(/^(.+)~([a-z0-
|
|
203
|
+
const m2 = filename.match(/^(.+)~([a-z0-9_]{10,})\.([a-z0-9]+)\.metadata\.json$/);
|
|
189
204
|
if (m2) return { naturalBase: `${m2[1]}.${m2[3]}`, uid: m2[2] };
|
|
190
205
|
|
|
191
206
|
return null;
|
|
@@ -210,7 +225,7 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
210
225
|
let entries;
|
|
211
226
|
try { entries = await readdir(dir); } catch { return null; }
|
|
212
227
|
|
|
213
|
-
// 1. Fast path: match naturalBase of
|
|
228
|
+
// 1. Fast path: match naturalBase of metadata files
|
|
214
229
|
for (const entry of entries) {
|
|
215
230
|
const parsed = parseMetaFilename(entry);
|
|
216
231
|
if (parsed && (parsed.naturalBase === base || parsed.naturalBase === companionName)) {
|
|
@@ -218,7 +233,7 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
218
233
|
}
|
|
219
234
|
}
|
|
220
235
|
|
|
221
|
-
// 2. Scan all metadata files
|
|
236
|
+
// 2. Scan all metadata files for @reference match
|
|
222
237
|
for (const entry of entries) {
|
|
223
238
|
if (!isMetadataFile(entry)) continue;
|
|
224
239
|
const metaPath = join(dir, entry);
|
|
@@ -241,8 +256,10 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
241
256
|
}
|
|
242
257
|
|
|
243
258
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
259
|
+
* Update the UID field in a metadata file in-place.
|
|
260
|
+
* Previously renamed the file to include ~uid in the name; now the UID lives
|
|
261
|
+
* only inside the JSON content, so no rename is needed.
|
|
262
|
+
*
|
|
246
263
|
* Restores file timestamps from _LastUpdated.
|
|
247
264
|
*
|
|
248
265
|
* @param {Object} meta - Current metadata object
|
|
@@ -250,57 +267,28 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
250
267
|
* @param {string} uid - Newly assigned UID from server
|
|
251
268
|
* @param {string} lastUpdated - Server _LastUpdated value
|
|
252
269
|
* @param {string} serverTz - Timezone for timestamp conversion
|
|
253
|
-
* @returns {Promise<{ newMetaPath: string, newFilePath:
|
|
270
|
+
* @returns {Promise<{ newMetaPath: string, newFilePath: null, updatedMeta: Object }>}
|
|
254
271
|
*/
|
|
255
272
|
export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
|
|
256
|
-
const metaDir = dirname(metaPath);
|
|
257
|
-
const metaFilename = basename(metaPath);
|
|
258
|
-
|
|
259
|
-
// Already in new format — nothing to do
|
|
260
|
-
if (parseMetaFilename(metaFilename)?.uid === uid) {
|
|
261
|
-
return { newMetaPath: metaPath, newFilePath: null, updatedMeta: meta };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Determine naturalBase from the temp/old metadata filename
|
|
265
|
-
// Temp format from adopt.js: "colors.metadata.json" → naturalBase = "colors"
|
|
266
|
-
// Old tilde format: "colors~uid.metadata.json" → naturalBase = "colors"
|
|
267
|
-
let naturalBase;
|
|
268
|
-
const legacyParsed = detectLegacyTildeMetadata(metaFilename);
|
|
269
|
-
if (legacyParsed) {
|
|
270
|
-
naturalBase = legacyParsed.naturalBase;
|
|
271
|
-
} else if (metaFilename.endsWith('.metadata.json')) {
|
|
272
|
-
naturalBase = metaFilename.slice(0, -'.metadata.json'.length);
|
|
273
|
-
} else {
|
|
274
|
-
naturalBase = basename(metaFilename, '.json');
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const newMetaPath = join(metaDir, buildMetaFilename(naturalBase, uid));
|
|
278
|
-
|
|
279
|
-
// Update only the UID field; @reference values are UNCHANGED (companions keep natural names)
|
|
280
273
|
const updatedMeta = { ...meta, UID: uid };
|
|
281
274
|
|
|
282
|
-
// Write updated metadata to
|
|
283
|
-
await writeFile(
|
|
284
|
-
|
|
285
|
-
// Remove old metadata file (new content already written to newMetaPath above)
|
|
286
|
-
if (metaPath !== newMetaPath) {
|
|
287
|
-
try { await unlink(metaPath); } catch { /* old file already gone */ }
|
|
288
|
-
}
|
|
275
|
+
// Write updated metadata to same path (no rename — UID is in the JSON, not the filename)
|
|
276
|
+
await writeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
|
|
289
277
|
|
|
290
|
-
// Restore timestamps for metadata file and companions
|
|
278
|
+
// Restore timestamps for metadata file and companions
|
|
291
279
|
if (serverTz && lastUpdated) {
|
|
292
280
|
const { setFileTimestamps } = await import('./timestamps.js');
|
|
293
|
-
try { await setFileTimestamps(
|
|
281
|
+
try { await setFileTimestamps(metaPath, lastUpdated, lastUpdated, serverTz); } catch {}
|
|
294
282
|
const contentCols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
|
|
295
283
|
if (meta._mediaFile) contentCols.push('_mediaFile');
|
|
296
284
|
for (const col of contentCols) {
|
|
297
285
|
const ref = updatedMeta[col];
|
|
298
286
|
if (ref && String(ref).startsWith('@')) {
|
|
299
|
-
const fp = join(
|
|
287
|
+
const fp = join(dirname(metaPath), String(ref).substring(1));
|
|
300
288
|
try { await setFileTimestamps(fp, lastUpdated, lastUpdated, serverTz); } catch {}
|
|
301
289
|
}
|
|
302
290
|
}
|
|
303
291
|
}
|
|
304
292
|
|
|
305
|
-
return { newMetaPath, newFilePath: null, updatedMeta };
|
|
293
|
+
return { newMetaPath: metaPath, newFilePath: null, updatedMeta };
|
|
306
294
|
}
|
package/src/lib/ignore.js
CHANGED