@dboio/cli 0.13.2 → 0.15.1
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 +100 -103
- 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/clone.js
CHANGED
|
@@ -6,14 +6,15 @@ import { DboClient } from '../lib/client.js';
|
|
|
6
6
|
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
|
|
7
7
|
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath } from '../lib/structure.js';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
|
-
import { buildUidFilename, buildContentFileName, stripUidFromFilename, hasUidInFilename
|
|
9
|
+
import { buildUidFilename, buildContentFileName, buildMetaFilename, isMetadataFile, parseMetaFilename, stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
|
|
10
10
|
import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
|
|
11
11
|
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache, findMetadataFiles } from '../lib/diff.js';
|
|
12
12
|
import { loadIgnore } from '../lib/ignore.js';
|
|
13
13
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
14
|
-
import { applyTrashIcon, ensureTrashIcon } from '../lib/
|
|
14
|
+
import { applyTrashIcon, ensureTrashIcon, tagProjectFiles } from '../lib/tagging.js';
|
|
15
15
|
import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
|
|
16
16
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
17
|
+
import { upsertDeployEntry } from '../lib/deploy-config.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Resolve a column value that may be base64-encoded.
|
|
@@ -284,9 +285,8 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
|
|
|
284
285
|
const uid = String(record.UID || record._id || 'untitled');
|
|
285
286
|
// Companion: natural name, no UID
|
|
286
287
|
const filename = sanitizeFilename(buildContentFileName(record, uid));
|
|
287
|
-
// Metadata:
|
|
288
|
-
const
|
|
289
|
-
const metaPath = join(dir, `${metaBase}.metadata.json`);
|
|
288
|
+
// Metadata: name.metadata~uid.json
|
|
289
|
+
const metaPath = join(dir, buildMetaFilename(name, uid));
|
|
290
290
|
|
|
291
291
|
return { dir, filename, metaPath };
|
|
292
292
|
}
|
|
@@ -315,10 +315,10 @@ export function resolveMediaPaths(record, structure) {
|
|
|
315
315
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
316
316
|
if (!dir) dir = BINS_DIR;
|
|
317
317
|
|
|
318
|
-
// Metadata: name
|
|
318
|
+
// Metadata: name.ext.metadata~uid.json
|
|
319
319
|
const uid = String(record.UID || record._id || 'untitled');
|
|
320
|
-
const
|
|
321
|
-
const metaPath = join(dir,
|
|
320
|
+
const naturalMediaBase = `${name}.${ext}`;
|
|
321
|
+
const metaPath = join(dir, buildMetaFilename(naturalMediaBase, uid));
|
|
322
322
|
|
|
323
323
|
return { dir, filename: companionFilename, metaPath };
|
|
324
324
|
}
|
|
@@ -326,6 +326,9 @@ export function resolveMediaPaths(record, structure) {
|
|
|
326
326
|
/**
|
|
327
327
|
* Extract path components for entity-dir records.
|
|
328
328
|
* Simplified from processEntityDirEntries() for collision detection.
|
|
329
|
+
*
|
|
330
|
+
* Returns `name` (natural, no ~UID) for companion files and
|
|
331
|
+
* `metaPath` (with ~UID) for the metadata file.
|
|
329
332
|
*/
|
|
330
333
|
export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
331
334
|
let name;
|
|
@@ -338,9 +341,8 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
|
338
341
|
}
|
|
339
342
|
|
|
340
343
|
const uid = record.UID || 'untitled';
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
return { dir: dirName, filename: finalName, metaPath };
|
|
344
|
+
const metaPath = join(dirName, buildMetaFilename(name, uid));
|
|
345
|
+
return { dir: dirName, name, metaPath };
|
|
344
346
|
}
|
|
345
347
|
|
|
346
348
|
/**
|
|
@@ -513,7 +515,7 @@ async function trashOrphanedLegacyCompanions() {
|
|
|
513
515
|
continue;
|
|
514
516
|
}
|
|
515
517
|
|
|
516
|
-
if (entry.name
|
|
518
|
+
if (isMetadataFile(entry.name)) {
|
|
517
519
|
// Read metadata and collect all @references
|
|
518
520
|
try {
|
|
519
521
|
const meta = JSON.parse(await readFile(full, 'utf8'));
|
|
@@ -598,9 +600,9 @@ async function buildFileRegistry(appJson, structure, placementPrefs) {
|
|
|
598
600
|
registry.get(filePath).push(entry);
|
|
599
601
|
}
|
|
600
602
|
|
|
601
|
-
// Note: entity-dir records (Extensions/, Data Sources/, etc.)
|
|
602
|
-
//
|
|
603
|
-
// Only content and media records in Bins/ are checked for cross-entity collisions.
|
|
603
|
+
// Note: entity-dir records (Extensions/, Data Sources/, etc.) use natural names for
|
|
604
|
+
// companion files with -N suffix collision resolution handled inline during processing.
|
|
605
|
+
// Only content and media records in Bins/ are checked for cross-entity collisions here.
|
|
604
606
|
|
|
605
607
|
return registry;
|
|
606
608
|
}
|
|
@@ -1288,6 +1290,9 @@ export async function performClone(source, options = {}) {
|
|
|
1288
1290
|
// Step 9: Trash orphaned legacy ~UID companion files that no metadata references
|
|
1289
1291
|
await trashOrphanedLegacyCompanions();
|
|
1290
1292
|
|
|
1293
|
+
// Step 10: Tag project files with sync status (best-effort, non-blocking)
|
|
1294
|
+
tagProjectFiles({ verbose: false }).catch(() => {});
|
|
1295
|
+
|
|
1291
1296
|
log.plain('');
|
|
1292
1297
|
const verb = options.pullMode ? 'Pull' : 'Clone';
|
|
1293
1298
|
log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
|
|
@@ -1570,6 +1575,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1570
1575
|
const refs = [];
|
|
1571
1576
|
const bulkAction = { value: null };
|
|
1572
1577
|
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
1578
|
+
const usedNames = new Map(); // name → count, for collision resolution
|
|
1573
1579
|
const config = await loadConfig();
|
|
1574
1580
|
|
|
1575
1581
|
// Determine filename column: saved preference, or prompt, or default
|
|
@@ -1717,19 +1723,27 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1717
1723
|
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
1718
1724
|
}
|
|
1719
1725
|
|
|
1720
|
-
//
|
|
1726
|
+
// Resolve name collisions: second+ record with same name gets -1, -2, etc.
|
|
1721
1727
|
const uid = record.UID || 'untitled';
|
|
1722
|
-
const
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1728
|
+
const nameKey = name;
|
|
1729
|
+
const count = usedNames.get(nameKey) || 0;
|
|
1730
|
+
usedNames.set(nameKey, count + 1);
|
|
1731
|
+
if (count > 0) name = `${name}-${count}`;
|
|
1732
|
+
|
|
1733
|
+
// Metadata: name.metadata~uid.json; companion files use natural name
|
|
1734
|
+
const metaPath = join(dirName, buildMetaFilename(name, uid));
|
|
1735
|
+
|
|
1736
|
+
// Legacy detection: rename old-format metadata files to new convention
|
|
1737
|
+
const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
|
|
1738
|
+
const legacyTildeMetaPath = join(dirName, `${buildUidFilename(name, uid)}.metadata.json`);
|
|
1739
|
+
const legacyPath = !await fileExists(metaPath) && await fileExists(legacyDotMetaPath) ? legacyDotMetaPath
|
|
1740
|
+
: !await fileExists(metaPath) && await fileExists(legacyTildeMetaPath) ? legacyTildeMetaPath
|
|
1741
|
+
: null;
|
|
1742
|
+
if (legacyPath) {
|
|
1729
1743
|
if (options.yes || legacyRenameAction.value === 'rename_all') {
|
|
1730
1744
|
const { rename: fsRename } = await import('fs/promises');
|
|
1731
|
-
await fsRename(
|
|
1732
|
-
log.dim(` Auto-renamed: ${basename(
|
|
1745
|
+
await fsRename(legacyPath, metaPath);
|
|
1746
|
+
log.dim(` Auto-renamed: ${basename(legacyPath)} → ${basename(metaPath)}`);
|
|
1733
1747
|
} else if (legacyRenameAction.value === 'skip_all') {
|
|
1734
1748
|
// skip silently
|
|
1735
1749
|
} else {
|
|
@@ -1737,7 +1751,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1737
1751
|
const { action } = await inquirer.prompt([{
|
|
1738
1752
|
type: 'list',
|
|
1739
1753
|
name: 'action',
|
|
1740
|
-
message: `Found legacy filename "${basename(
|
|
1754
|
+
message: `Found legacy filename "${basename(legacyPath)}" — rename to "${basename(metaPath)}"?`,
|
|
1741
1755
|
choices: [
|
|
1742
1756
|
{ name: 'Yes', value: 'rename' },
|
|
1743
1757
|
{ name: 'Rename all remaining', value: 'rename_all' },
|
|
@@ -1747,8 +1761,8 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1747
1761
|
}]);
|
|
1748
1762
|
if (action === 'rename' || action === 'rename_all') {
|
|
1749
1763
|
const { rename: fsRename } = await import('fs/promises');
|
|
1750
|
-
await fsRename(
|
|
1751
|
-
log.success(` Renamed: ${basename(
|
|
1764
|
+
await fsRename(legacyPath, metaPath);
|
|
1765
|
+
log.success(` Renamed: ${basename(legacyPath)} → ${basename(metaPath)}`);
|
|
1752
1766
|
if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
|
|
1753
1767
|
} else {
|
|
1754
1768
|
if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
|
|
@@ -1765,7 +1779,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1765
1779
|
const entityMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteEntityMeta);
|
|
1766
1780
|
if (entityMetaExists && !options.yes && !hasNewExtractions) {
|
|
1767
1781
|
if (bulkAction.value === 'skip_all') {
|
|
1768
|
-
log.dim(` Skipped ${
|
|
1782
|
+
log.dim(` Skipped ${name}`);
|
|
1769
1783
|
refs.push({ uid: record.UID, metaPath });
|
|
1770
1784
|
continue;
|
|
1771
1785
|
}
|
|
@@ -1787,22 +1801,22 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1787
1801
|
if (serverNewer) {
|
|
1788
1802
|
// Incomplete metadata (no _LastUpdated) from dbo add — auto-accept without prompting
|
|
1789
1803
|
if (localMissingLastUpdated) {
|
|
1790
|
-
log.dim(` Completing metadata: ${
|
|
1804
|
+
log.dim(` Completing metadata: ${name}`);
|
|
1791
1805
|
// Fall through to write
|
|
1792
1806
|
} else {
|
|
1793
|
-
const action = await promptChangeDetection(
|
|
1807
|
+
const action = await promptChangeDetection(name, record, configWithTz, {
|
|
1794
1808
|
serverDate,
|
|
1795
1809
|
localDate: localSyncTime,
|
|
1796
1810
|
});
|
|
1797
1811
|
|
|
1798
1812
|
if (action === 'skip') {
|
|
1799
|
-
log.dim(` Skipped ${
|
|
1813
|
+
log.dim(` Skipped ${name}`);
|
|
1800
1814
|
refs.push({ uid: record.UID, metaPath });
|
|
1801
1815
|
continue;
|
|
1802
1816
|
}
|
|
1803
1817
|
if (action === 'skip_all') {
|
|
1804
1818
|
bulkAction.value = 'skip_all';
|
|
1805
|
-
log.dim(` Skipped ${
|
|
1819
|
+
log.dim(` Skipped ${name}`);
|
|
1806
1820
|
refs.push({ uid: record.UID, metaPath });
|
|
1807
1821
|
continue;
|
|
1808
1822
|
}
|
|
@@ -1818,20 +1832,20 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1818
1832
|
} else {
|
|
1819
1833
|
const locallyModified = await hasLocalModifications(metaPath, configWithTz);
|
|
1820
1834
|
if (locallyModified) {
|
|
1821
|
-
const action = await promptChangeDetection(
|
|
1835
|
+
const action = await promptChangeDetection(name, record, configWithTz, {
|
|
1822
1836
|
localIsNewer: true,
|
|
1823
1837
|
serverDate,
|
|
1824
1838
|
localDate: localSyncTime,
|
|
1825
1839
|
});
|
|
1826
1840
|
|
|
1827
1841
|
if (action === 'skip') {
|
|
1828
|
-
log.dim(` Kept local: ${
|
|
1842
|
+
log.dim(` Kept local: ${name}`);
|
|
1829
1843
|
refs.push({ uid: record.UID, metaPath });
|
|
1830
1844
|
continue;
|
|
1831
1845
|
}
|
|
1832
1846
|
if (action === 'skip_all') {
|
|
1833
1847
|
bulkAction.value = 'skip_all';
|
|
1834
|
-
log.dim(` Kept local: ${
|
|
1848
|
+
log.dim(` Kept local: ${name}`);
|
|
1835
1849
|
refs.push({ uid: record.UID, metaPath });
|
|
1836
1850
|
continue;
|
|
1837
1851
|
}
|
|
@@ -1844,7 +1858,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1844
1858
|
continue;
|
|
1845
1859
|
}
|
|
1846
1860
|
} else {
|
|
1847
|
-
log.dim(` Up to date: ${
|
|
1861
|
+
log.dim(` Up to date: ${name}`);
|
|
1848
1862
|
refs.push({ uid: record.UID, metaPath });
|
|
1849
1863
|
continue;
|
|
1850
1864
|
}
|
|
@@ -1864,9 +1878,10 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1864
1878
|
if (extractInfo) {
|
|
1865
1879
|
const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
|
|
1866
1880
|
const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
|
|
1867
|
-
const colFileName = `${
|
|
1881
|
+
const colFileName = `${name}.${key}.${extractInfo.ext}`;
|
|
1868
1882
|
const colFilePath = join(dirName, colFileName);
|
|
1869
1883
|
await writeFile(colFilePath, decoded);
|
|
1884
|
+
await upsertDeployEntry(colFilePath, record.UID, entityName, key);
|
|
1870
1885
|
meta[key] = `@${colFileName}`;
|
|
1871
1886
|
extractedContentCols.push(key);
|
|
1872
1887
|
|
|
@@ -2198,6 +2213,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2198
2213
|
const { filenameCol, contentColsToExtract } = descriptorPrefs.get(descriptor);
|
|
2199
2214
|
const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
|
|
2200
2215
|
const mdColInfo = useRootDoc ? contentColsToExtract.find(c => c.ext === 'md') : null;
|
|
2216
|
+
const usedNames = new Map(); // name → count, for collision resolution within this descriptor group
|
|
2201
2217
|
|
|
2202
2218
|
log.info(`Processing ${records.length} "${descriptor}" extension(s) → ${dir}/`);
|
|
2203
2219
|
|
|
@@ -2212,17 +2228,27 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2212
2228
|
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
2213
2229
|
}
|
|
2214
2230
|
|
|
2231
|
+
// Resolve name collisions: second+ record with same name gets -1, -2, etc.
|
|
2215
2232
|
const uid = record.UID || 'untitled';
|
|
2216
|
-
const
|
|
2217
|
-
const
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2233
|
+
const nameKey = name;
|
|
2234
|
+
const nameCount = usedNames.get(nameKey) || 0;
|
|
2235
|
+
usedNames.set(nameKey, nameCount + 1);
|
|
2236
|
+
if (nameCount > 0) name = `${name}-${nameCount}`;
|
|
2237
|
+
|
|
2238
|
+
// Metadata: name.metadata~uid.json; companion files use natural name
|
|
2239
|
+
const metaPath = join(dir, buildMetaFilename(name, uid));
|
|
2240
|
+
|
|
2241
|
+
// Legacy detection: rename old-format metadata files to new convention
|
|
2242
|
+
const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
|
|
2243
|
+
const legacyTildeExtMetaPath = join(dir, `${buildUidFilename(name, uid)}.metadata.json`);
|
|
2244
|
+
const legacyExtPath = !await fileExists(metaPath) && await fileExists(legacyDotExtMetaPath) ? legacyDotExtMetaPath
|
|
2245
|
+
: !await fileExists(metaPath) && await fileExists(legacyTildeExtMetaPath) ? legacyTildeExtMetaPath
|
|
2246
|
+
: null;
|
|
2247
|
+
if (legacyExtPath) {
|
|
2222
2248
|
if (options.yes || legacyRenameAction.value === 'rename_all') {
|
|
2223
2249
|
const { rename: fsRename } = await import('fs/promises');
|
|
2224
|
-
await fsRename(
|
|
2225
|
-
log.dim(` Auto-renamed: ${basename(
|
|
2250
|
+
await fsRename(legacyExtPath, metaPath);
|
|
2251
|
+
log.dim(` Auto-renamed: ${basename(legacyExtPath)} → ${basename(metaPath)}`);
|
|
2226
2252
|
} else if (legacyRenameAction.value === 'skip_all') {
|
|
2227
2253
|
// skip silently
|
|
2228
2254
|
} else {
|
|
@@ -2230,7 +2256,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2230
2256
|
const { action } = await inquirer.prompt([{
|
|
2231
2257
|
type: 'list',
|
|
2232
2258
|
name: 'action',
|
|
2233
|
-
message: `Found legacy filename "${basename(
|
|
2259
|
+
message: `Found legacy filename "${basename(legacyExtPath)}" — rename to "${basename(metaPath)}"?`,
|
|
2234
2260
|
choices: [
|
|
2235
2261
|
{ name: 'Yes', value: 'rename' },
|
|
2236
2262
|
{ name: 'Rename all remaining', value: 'rename_all' },
|
|
@@ -2240,8 +2266,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2240
2266
|
}]);
|
|
2241
2267
|
if (action === 'rename' || action === 'rename_all') {
|
|
2242
2268
|
const { rename: fsRename } = await import('fs/promises');
|
|
2243
|
-
await fsRename(
|
|
2244
|
-
log.success(` Renamed: ${basename(
|
|
2269
|
+
await fsRename(legacyExtPath, metaPath);
|
|
2270
|
+
log.success(` Renamed: ${basename(legacyExtPath)} → ${basename(metaPath)}`);
|
|
2245
2271
|
if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
|
|
2246
2272
|
} else {
|
|
2247
2273
|
if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
|
|
@@ -2282,7 +2308,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2282
2308
|
const extMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteExtMeta);
|
|
2283
2309
|
if (extMetaExists && !options.yes && !hasNewExtractions) {
|
|
2284
2310
|
if (bulkAction.value === 'skip_all') {
|
|
2285
|
-
log.dim(` Skipped ${
|
|
2311
|
+
log.dim(` Skipped ${name}`);
|
|
2286
2312
|
refs.push({ uid: record.UID, metaPath });
|
|
2287
2313
|
continue;
|
|
2288
2314
|
}
|
|
@@ -2293,7 +2319,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2293
2319
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
2294
2320
|
|
|
2295
2321
|
if (serverNewer) {
|
|
2296
|
-
const action = await promptChangeDetection(
|
|
2322
|
+
const action = await promptChangeDetection(name, record, cfgWithTz, { serverDate, localDate: localSyncTime });
|
|
2297
2323
|
if (action === 'skip') { refs.push({ uid: record.UID, metaPath }); continue; }
|
|
2298
2324
|
if (action === 'skip_all') { bulkAction.value = 'skip_all'; refs.push({ uid: record.UID, metaPath }); continue; }
|
|
2299
2325
|
if (action === 'overwrite_all') { bulkAction.value = 'overwrite_all'; }
|
|
@@ -2301,13 +2327,13 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2301
2327
|
} else {
|
|
2302
2328
|
const localModified = await hasLocalModifications(metaPath, cfgWithTz);
|
|
2303
2329
|
if (localModified) {
|
|
2304
|
-
const action = await promptChangeDetection(
|
|
2330
|
+
const action = await promptChangeDetection(name, record, cfgWithTz, { localIsNewer: true, serverDate, localDate: localSyncTime });
|
|
2305
2331
|
if (action === 'skip') { refs.push({ uid: record.UID, metaPath }); continue; }
|
|
2306
2332
|
if (action === 'skip_all') { bulkAction.value = 'skip_all'; refs.push({ uid: record.UID, metaPath }); continue; }
|
|
2307
2333
|
if (action === 'overwrite_all') { bulkAction.value = 'overwrite_all'; }
|
|
2308
2334
|
if (action === 'compare') { await inlineDiffAndMerge(record, metaPath, cfgWithTz, { localIsNewer: true }); refs.push({ uid: record.UID, metaPath }); continue; }
|
|
2309
2335
|
} else {
|
|
2310
|
-
log.dim(` Up to date: ${
|
|
2336
|
+
log.dim(` Up to date: ${name}`);
|
|
2311
2337
|
refs.push({ uid: record.UID, metaPath });
|
|
2312
2338
|
continue;
|
|
2313
2339
|
}
|
|
@@ -2334,13 +2360,14 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2334
2360
|
colFilePath = join(DOCUMENTATION_DIR, docFileName);
|
|
2335
2361
|
refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
|
|
2336
2362
|
} else {
|
|
2337
|
-
const colFileName = `${
|
|
2363
|
+
const colFileName = `${name}.${key}.${extractInfo.ext}`;
|
|
2338
2364
|
colFilePath = join(dir, colFileName);
|
|
2339
2365
|
refValue = `@${colFileName}`;
|
|
2340
2366
|
}
|
|
2341
2367
|
|
|
2342
2368
|
meta[key] = refValue;
|
|
2343
2369
|
await writeFile(colFilePath, decoded);
|
|
2370
|
+
await upsertDeployEntry(colFilePath, record.UID, 'extension', key);
|
|
2344
2371
|
extractedCols.push(key);
|
|
2345
2372
|
if (serverTz) {
|
|
2346
2373
|
try { await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz); } catch {}
|
|
@@ -2498,9 +2525,9 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2498
2525
|
const uid = String(record.UID || record._id || 'untitled');
|
|
2499
2526
|
const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
|
|
2500
2527
|
const filePath = join(dir, finalFilename);
|
|
2501
|
-
// Metadata: name
|
|
2502
|
-
const
|
|
2503
|
-
const metaPath = join(dir,
|
|
2528
|
+
// Metadata: name.ext.metadata~uid.json
|
|
2529
|
+
const naturalMediaBase = `${name}.${ext}`;
|
|
2530
|
+
const metaPath = join(dir, buildMetaFilename(naturalMediaBase, uid));
|
|
2504
2531
|
// usedNames retained for tracking
|
|
2505
2532
|
const fileKey = `${dir}/${name}.${ext}`;
|
|
2506
2533
|
usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
|
|
@@ -2671,6 +2698,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2671
2698
|
meta._mediaFile = `@${finalFilename}`;
|
|
2672
2699
|
|
|
2673
2700
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
2701
|
+
await upsertDeployEntry(filePath, record.UID, 'media', 'File');
|
|
2674
2702
|
log.dim(` → ${metaPath}`);
|
|
2675
2703
|
|
|
2676
2704
|
// Set file timestamps from server dates (independent try-catch so one failure
|
|
@@ -2742,7 +2770,6 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2742
2770
|
try {
|
|
2743
2771
|
const uid = String(record.UID);
|
|
2744
2772
|
const sanitized = sanitizeFilename(String(record.Name || uid || 'untitled'));
|
|
2745
|
-
const probe = buildUidFilename(sanitized, uid);
|
|
2746
2773
|
// Resolve the directory the same way the main code does below
|
|
2747
2774
|
let probeDir = BINS_DIR;
|
|
2748
2775
|
if (record.BinID && structure[record.BinID]) {
|
|
@@ -2753,7 +2780,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2753
2780
|
probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
|
|
2754
2781
|
if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
|
|
2755
2782
|
|
|
2756
|
-
const probeMeta = join(probeDir,
|
|
2783
|
+
const probeMeta = join(probeDir, buildMetaFilename(sanitized, uid));
|
|
2757
2784
|
const raw = await readFile(probeMeta, 'utf8');
|
|
2758
2785
|
const localMeta = JSON.parse(raw);
|
|
2759
2786
|
// Extract extension from Content @reference (e.g. "@Name~uid.html")
|
|
@@ -2842,8 +2869,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2842
2869
|
const uid = String(record.UID || record._id || 'untitled');
|
|
2843
2870
|
// Companion: natural name, no UID (use collision-resolved override if available)
|
|
2844
2871
|
const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
|
|
2845
|
-
// Metadata:
|
|
2846
|
-
const metaBase = buildUidFilename(name, uid);
|
|
2872
|
+
// Metadata: name.metadata~uid.json
|
|
2847
2873
|
// usedNames retained for non-UID edge case tracking
|
|
2848
2874
|
const nameKey = `${dir}/${name}`;
|
|
2849
2875
|
usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
|
|
@@ -2856,7 +2882,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2856
2882
|
);
|
|
2857
2883
|
|
|
2858
2884
|
const filePath = join(dir, fileName);
|
|
2859
|
-
const metaPath = join(dir,
|
|
2885
|
+
const metaPath = join(dir, buildMetaFilename(name, uid));
|
|
2860
2886
|
|
|
2861
2887
|
// Rename legacy ~UID companion files to natural names if needed
|
|
2862
2888
|
if (await fileExists(metaPath)) {
|
|
@@ -2961,6 +2987,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2961
2987
|
if (hasContent) {
|
|
2962
2988
|
const decoded = resolveContentValue(contentValue) ?? '';
|
|
2963
2989
|
await writeFile(filePath, decoded);
|
|
2990
|
+
await upsertDeployEntry(filePath, record.UID, entityName, 'Content');
|
|
2964
2991
|
log.success(`Saved ${filePath}`);
|
|
2965
2992
|
}
|
|
2966
2993
|
|
|
@@ -2976,11 +3003,12 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2976
3003
|
// Other base64 columns — decode and store inline or as reference
|
|
2977
3004
|
const decoded = resolveContentValue(value);
|
|
2978
3005
|
if (decoded && decoded.length > 200) {
|
|
2979
|
-
// Large value: save as separate file
|
|
3006
|
+
// Large value: save as separate file (natural name, no ~UID)
|
|
2980
3007
|
const colExt = guessExtensionForColumn(key);
|
|
2981
|
-
const colFileName = `${
|
|
3008
|
+
const colFileName = `${name}-${key.toLowerCase()}.${colExt}`;
|
|
2982
3009
|
const colFilePath = join(dir, colFileName);
|
|
2983
3010
|
await writeFile(colFilePath, decoded);
|
|
3011
|
+
await upsertDeployEntry(colFilePath, record.UID, entityName, key);
|
|
2984
3012
|
meta[key] = `@${colFileName}`;
|
|
2985
3013
|
if (!meta._contentColumns) meta._contentColumns = [];
|
|
2986
3014
|
meta._contentColumns.push(key);
|
|
@@ -3233,17 +3261,11 @@ async function resolveOutputFilenameColumns(appJson, options) {
|
|
|
3233
3261
|
export function buildOutputFilename(entityType, node, filenameCol, parentChain = []) {
|
|
3234
3262
|
const uid = node.UID || '';
|
|
3235
3263
|
const rawName = node[filenameCol];
|
|
3236
|
-
const name = rawName ? sanitizeFilename(String(rawName)) :
|
|
3264
|
+
const name = rawName ? sanitizeFilename(String(rawName)) : uid;
|
|
3237
3265
|
|
|
3238
|
-
//
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
// Root output: use Name~UID directly (no type prefix)
|
|
3242
|
-
segment = (!name || name === uid) ? uid : `${name}~${uid}`;
|
|
3243
|
-
} else {
|
|
3244
|
-
// Child entities: keep type prefix (column~, join~, filter~)
|
|
3245
|
-
segment = (!name || name === uid) ? `${entityType}~${uid}` : `${entityType}~${name}~${uid}`;
|
|
3246
|
-
}
|
|
3266
|
+
// Root output: use natural name only (UID goes to .metadata~uid.json, not the stem)
|
|
3267
|
+
// Child entities: docName only, no ~uid (index determines uniqueness)
|
|
3268
|
+
const segment = entityType === 'output' ? name : entityType;
|
|
3247
3269
|
|
|
3248
3270
|
const allSegments = [...parentChain, segment];
|
|
3249
3271
|
return allSegments.join('.');
|
|
@@ -3257,18 +3279,21 @@ const INLINE_DOC_KEYS = ['column', 'join', 'filter'];
|
|
|
3257
3279
|
|
|
3258
3280
|
/**
|
|
3259
3281
|
* Build the companion file stem for a child entity within a root output file.
|
|
3260
|
-
*
|
|
3261
|
-
*
|
|
3282
|
+
* Uses array index for uniqueness (index 0 = no suffix, index N = "-N" suffix).
|
|
3283
|
+
*
|
|
3284
|
+
* e.g. rootStem "Sales", entity "output_value", index 0 → "Sales.column"
|
|
3285
|
+
* rootStem "Sales", entity "output_value", index 1 → "Sales.column-1"
|
|
3262
3286
|
*
|
|
3263
|
-
* @param {string} rootStem - Root output
|
|
3287
|
+
* @param {string} rootStem - Root output natural name (e.g. "Sales")
|
|
3264
3288
|
* @param {string} physicalEntity - Physical entity name ('output_value', etc.)
|
|
3265
|
-
* @param {
|
|
3289
|
+
* @param {number} index - Zero-based position of this child in its array
|
|
3266
3290
|
* @param {string} [parentChainStem] - Already-built ancestor stem (for nested children)
|
|
3267
3291
|
* @returns {string}
|
|
3268
3292
|
*/
|
|
3269
|
-
export function getChildCompanionStem(rootStem, physicalEntity,
|
|
3293
|
+
export function getChildCompanionStem(rootStem, physicalEntity, index, parentChainStem = rootStem) {
|
|
3270
3294
|
const docName = INLINE_DOC_NAMES[physicalEntity] || physicalEntity;
|
|
3271
|
-
|
|
3295
|
+
const suffix = index === 0 ? '' : `-${index}`;
|
|
3296
|
+
return `${parentChainStem}.${docName}${suffix}`;
|
|
3272
3297
|
}
|
|
3273
3298
|
|
|
3274
3299
|
/**
|
|
@@ -3305,6 +3330,7 @@ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, ser
|
|
|
3305
3330
|
const companionName = `${companionStem}.CustomSQL.sql`;
|
|
3306
3331
|
const companionPath = join(outputDir, companionName);
|
|
3307
3332
|
await writeFile(companionPath, hasContent ? decoded : '', 'utf8');
|
|
3333
|
+
await upsertDeployEntry(companionPath, entityObj.UID || entityObj._uid, 'output', 'CustomSQL');
|
|
3308
3334
|
entityObj.CustomSQL = `@${companionName}`;
|
|
3309
3335
|
entityObj._contentColumns = entityObj._contentColumns || [];
|
|
3310
3336
|
if (!entityObj._contentColumns.includes('CustomSQL')) {
|
|
@@ -3350,7 +3376,8 @@ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, s
|
|
|
3350
3376
|
|
|
3351
3377
|
if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
|
|
3352
3378
|
|
|
3353
|
-
for (
|
|
3379
|
+
for (let childIdx = 0; childIdx < entityArray.length; childIdx++) {
|
|
3380
|
+
const child = entityArray[childIdx];
|
|
3354
3381
|
// Build a clean copy without tree-internal fields
|
|
3355
3382
|
const childObj = { ...child };
|
|
3356
3383
|
delete childObj._children;
|
|
@@ -3366,8 +3393,8 @@ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, s
|
|
|
3366
3393
|
// Ensure _entity is set to physical entity name (for push routing)
|
|
3367
3394
|
childObj._entity = physicalKey;
|
|
3368
3395
|
|
|
3369
|
-
// Compute companion stem for this child
|
|
3370
|
-
const childStem = getChildCompanionStem(rootStem, physicalKey,
|
|
3396
|
+
// Compute companion stem for this child (index-based, not UID-based)
|
|
3397
|
+
const childStem = getChildCompanionStem(rootStem, physicalKey, childIdx, parentStem);
|
|
3371
3398
|
|
|
3372
3399
|
// Extract CustomSQL if needed
|
|
3373
3400
|
const companionFile = await extractCustomSqlIfNeeded(childObj, childStem, outputDir, serverTz);
|
|
@@ -3419,8 +3446,8 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
|
3419
3446
|
log.dim(` Trashed orphaned child file: ${f}`);
|
|
3420
3447
|
} catch { /* non-critical */ }
|
|
3421
3448
|
}
|
|
3422
|
-
// Also trash the legacy root file itself (_output~Name~UID.json or .metadata.json)
|
|
3423
|
-
if (
|
|
3449
|
+
// Also trash the legacy root file itself (_output~Name~UID.json or .metadata.json)
|
|
3450
|
+
if (!matchesCurrent && (f === `${legacyStem}.json` || f === `${legacyStem}.metadata.json`)) {
|
|
3424
3451
|
if (!trashCreated) {
|
|
3425
3452
|
await mkdir(trashDir, { recursive: true });
|
|
3426
3453
|
trashCreated = true;
|
|
@@ -3447,10 +3474,16 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
|
3447
3474
|
* @returns {Object} - { segments: [{entity, name, uid}], rootOutputUid, entityType, uid }
|
|
3448
3475
|
*/
|
|
3449
3476
|
export function parseOutputHierarchyFile(filename) {
|
|
3450
|
-
// Strip
|
|
3477
|
+
// Strip metadata or .json extension
|
|
3451
3478
|
let base = filename;
|
|
3452
|
-
|
|
3453
|
-
|
|
3479
|
+
const metaParsed = parseMetaFilename(filename);
|
|
3480
|
+
if (metaParsed) {
|
|
3481
|
+
base = metaParsed.naturalBase;
|
|
3482
|
+
} else if (base.endsWith('.metadata.json')) {
|
|
3483
|
+
base = base.substring(0, base.length - 14);
|
|
3484
|
+
} else if (base.endsWith('.json')) {
|
|
3485
|
+
base = base.substring(0, base.length - 5);
|
|
3486
|
+
}
|
|
3454
3487
|
|
|
3455
3488
|
// Split into segments by finding entity type boundaries
|
|
3456
3489
|
// Entity types are: output~ (or legacy _output~), column~, join~, filter~
|
|
@@ -3571,15 +3604,26 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3571
3604
|
|
|
3572
3605
|
await mkdir(binDir, { recursive: true });
|
|
3573
3606
|
|
|
3574
|
-
// Build root output filename
|
|
3607
|
+
// Build root output filename (natural name, no UID in stem)
|
|
3575
3608
|
const rootBasename = buildOutputFilename('output', output, filenameCols.output);
|
|
3576
|
-
|
|
3609
|
+
const rootUid = output.UID || '';
|
|
3610
|
+
let rootMetaPath = join(binDir, buildMetaFilename(rootBasename, rootUid));
|
|
3577
3611
|
|
|
3578
|
-
// Legacy fallback:
|
|
3612
|
+
// Legacy fallback: rename old-format metadata to new convention
|
|
3613
|
+
const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
|
|
3579
3614
|
const legacyJsonPath = join(binDir, `${rootBasename}.json`);
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3615
|
+
const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
|
|
3616
|
+
if (!await fileExists(rootMetaPath)) {
|
|
3617
|
+
if (await fileExists(legacyTildeOutputMeta)) {
|
|
3618
|
+
await rename(legacyTildeOutputMeta, rootMetaPath);
|
|
3619
|
+
log.dim(` Renamed ${basename(legacyTildeOutputMeta)} → ${basename(rootMetaPath)}`);
|
|
3620
|
+
} else if (await fileExists(legacyOutputMeta)) {
|
|
3621
|
+
await rename(legacyOutputMeta, rootMetaPath);
|
|
3622
|
+
log.dim(` Renamed ${basename(legacyOutputMeta)} → ${basename(rootMetaPath)}`);
|
|
3623
|
+
} else if (await fileExists(legacyJsonPath)) {
|
|
3624
|
+
await rename(legacyJsonPath, rootMetaPath);
|
|
3625
|
+
log.dim(` Renamed ${rootBasename}.json → ${basename(rootMetaPath)}`);
|
|
3626
|
+
}
|
|
3583
3627
|
}
|
|
3584
3628
|
|
|
3585
3629
|
// Detect old-format files that need migration to inline children format.
|
package/src/commands/deploy.js
CHANGED
|
@@ -57,10 +57,17 @@ export const deployCommand = new Command('deploy')
|
|
|
57
57
|
process.exit(1);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// If name doesn't match a key directly, try scanning by UID value
|
|
61
|
+
let resolvedName = name;
|
|
62
|
+
if (name && !manifest.deployments[name]) {
|
|
63
|
+
const byUid = Object.entries(manifest.deployments).find(([, v]) => v && v.uid === name);
|
|
64
|
+
if (byUid) resolvedName = byUid[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
60
67
|
const entries = options.all
|
|
61
68
|
? Object.entries(manifest.deployments)
|
|
62
|
-
:
|
|
63
|
-
? [[
|
|
69
|
+
: resolvedName
|
|
70
|
+
? [[resolvedName, manifest.deployments[resolvedName]]]
|
|
64
71
|
: [];
|
|
65
72
|
|
|
66
73
|
if (entries.length === 0 || (entries.length === 1 && !entries[0][1])) {
|
package/src/commands/diff.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from '../lib/diff.js';
|
|
15
15
|
import { fetchServerRecordsBatch } from '../lib/toe-stepping.js';
|
|
16
16
|
import { findBaselineEntry } from '../lib/delta.js';
|
|
17
|
-
import { findMetadataForCompanion } from '../lib/filenames.js';
|
|
17
|
+
import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
|
|
18
18
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
19
19
|
|
|
20
20
|
export const diffCommand = new Command('diff')
|
|
@@ -82,7 +82,7 @@ export const diffCommand = new Command('diff')
|
|
|
82
82
|
let bulkAction = null;
|
|
83
83
|
|
|
84
84
|
for (const metaPath of metaFiles) {
|
|
85
|
-
const metaBase = basename(metaPath, '.metadata.json');
|
|
85
|
+
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
86
86
|
|
|
87
87
|
const result = await compareRecord(metaPath, config, serverRecordsMap);
|
|
88
88
|
|
|
@@ -204,7 +204,7 @@ async function resolveTargetToMetaFiles(targetPath) {
|
|
|
204
204
|
pathStat = await stat(targetPath);
|
|
205
205
|
} catch {
|
|
206
206
|
// Maybe it's a file without extension — try finding metadata
|
|
207
|
-
const metaPath = targetPath.endsWith('.metadata.json')
|
|
207
|
+
const metaPath = (isMetadataFile(basename(targetPath)) || targetPath.endsWith('.metadata.json'))
|
|
208
208
|
? targetPath
|
|
209
209
|
: `${targetPath}.metadata.json`;
|
|
210
210
|
|
|
@@ -221,7 +221,7 @@ async function resolveTargetToMetaFiles(targetPath) {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
// Single file — find its companion metadata
|
|
224
|
-
if (targetPath.endsWith('.metadata.json')) {
|
|
224
|
+
if (isMetadataFile(basename(targetPath)) || targetPath.endsWith('.metadata.json')) {
|
|
225
225
|
return [targetPath];
|
|
226
226
|
}
|
|
227
227
|
|