@dboio/cli 0.8.2 → 0.9.3
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 +134 -56
- package/package.json +1 -1
- package/src/commands/add.js +114 -10
- package/src/commands/clone.js +245 -85
- package/src/commands/deploy.js +1 -1
- package/src/commands/init.js +4 -3
- package/src/commands/input.js +2 -2
- package/src/commands/pull.js +1 -0
- package/src/commands/push.js +127 -27
- package/src/commands/rm.js +48 -16
- package/src/lib/filenames.js +151 -0
- package/src/lib/formatter.js +93 -11
- package/src/lib/ignore.js +8 -2
- package/src/lib/input-parser.js +30 -4
- package/src/lib/metadata-templates.js +264 -0
- package/src/lib/structure.js +30 -26
- package/src/lib/ticketing.js +73 -5
package/src/commands/clone.js
CHANGED
|
@@ -3,11 +3,13 @@ import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
|
|
3
3
|
import { join, basename, extname } from 'path';
|
|
4
4
|
import { DboClient } from '../lib/client.js';
|
|
5
5
|
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';
|
|
6
|
-
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS,
|
|
6
|
+
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir } from '../lib/structure.js';
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
|
+
import { buildUidFilename, detectLegacyDotUid } from '../lib/filenames.js';
|
|
8
9
|
import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
|
|
9
10
|
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable } from '../lib/diff.js';
|
|
10
11
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
12
|
+
import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Resolve a column value that may be base64-encoded.
|
|
@@ -32,6 +34,12 @@ async function fileExists(path) {
|
|
|
32
34
|
try { await access(path); return true; } catch { return false; }
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
const WILL_DELETE_PREFIX = '__WILL_DELETE__';
|
|
38
|
+
|
|
39
|
+
function isWillDeleteFile(filename) {
|
|
40
|
+
return basename(filename).startsWith(WILL_DELETE_PREFIX);
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
/**
|
|
36
44
|
* Resolve a content Path to a directory under Bins/.
|
|
37
45
|
*
|
|
@@ -132,8 +140,10 @@ function resolveRecordPaths(entityName, record, structure, placementPref) {
|
|
|
132
140
|
dir = dir.substring(0, dir.lastIndexOf('/')) || BINS_DIR;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
const
|
|
136
|
-
const
|
|
143
|
+
const uid = String(record.UID || record._id || 'untitled');
|
|
144
|
+
const base = buildUidFilename(name, uid);
|
|
145
|
+
const filename = ext ? `${base}.${ext}` : base;
|
|
146
|
+
const metaPath = join(dir, `${base}.metadata.json`);
|
|
137
147
|
|
|
138
148
|
return { dir, filename, metaPath };
|
|
139
149
|
}
|
|
@@ -169,7 +179,9 @@ function resolveMediaPaths(record, structure, placementPref) {
|
|
|
169
179
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
170
180
|
if (!dir) dir = BINS_DIR;
|
|
171
181
|
|
|
172
|
-
const
|
|
182
|
+
const uid = String(record.UID || record._id || 'untitled');
|
|
183
|
+
const base = buildUidFilename(name, uid);
|
|
184
|
+
const finalFilename = `${base}.${ext}`;
|
|
173
185
|
const metaPath = join(dir, `${finalFilename}.metadata.json`);
|
|
174
186
|
|
|
175
187
|
return { dir, filename: finalFilename, metaPath };
|
|
@@ -190,7 +202,7 @@ function resolveEntityDirPaths(entityName, record, dirName) {
|
|
|
190
202
|
}
|
|
191
203
|
|
|
192
204
|
const uid = record.UID || 'untitled';
|
|
193
|
-
const finalName = name
|
|
205
|
+
const finalName = buildUidFilename(name, uid);
|
|
194
206
|
const metaPath = join(dirName, `${finalName}.metadata.json`);
|
|
195
207
|
return { dir: dirName, filename: finalName, metaPath };
|
|
196
208
|
}
|
|
@@ -427,6 +439,7 @@ export const cloneCommand = new Command('clone')
|
|
|
427
439
|
.option('--force', 'Force re-processing of all files, skip change detection')
|
|
428
440
|
.option('--domain <host>', 'Override domain')
|
|
429
441
|
.option('-y, --yes', 'Auto-accept all prompts')
|
|
442
|
+
.option('--media-placement <placement>', 'Set media placement: fullpath or binpath (default: bin)')
|
|
430
443
|
.option('-v, --verbose', 'Show HTTP request details')
|
|
431
444
|
.action(async (source, options) => {
|
|
432
445
|
try {
|
|
@@ -762,6 +775,29 @@ export async function performClone(source, options = {}) {
|
|
|
762
775
|
for (const d of DEFAULT_PROJECT_DIRS) log.dim(` ${d}/`);
|
|
763
776
|
for (const d of createdDirs) log.dim(` ${d}/`);
|
|
764
777
|
|
|
778
|
+
// Warn about legacy mixed-case directories from pre-0.9.1
|
|
779
|
+
const LEGACY_DIR_MAP = {
|
|
780
|
+
'Bins': 'bins',
|
|
781
|
+
'Automations': 'automation',
|
|
782
|
+
'App Versions': 'app_version',
|
|
783
|
+
'Documentation': 'docs',
|
|
784
|
+
'Sites': 'site',
|
|
785
|
+
'Extensions': 'extension',
|
|
786
|
+
'Data Sources': 'data_source',
|
|
787
|
+
'Groups': 'group',
|
|
788
|
+
'Integrations': 'integration',
|
|
789
|
+
'Trash': 'trash',
|
|
790
|
+
'Src': 'src',
|
|
791
|
+
};
|
|
792
|
+
for (const [oldName, newName] of Object.entries(LEGACY_DIR_MAP)) {
|
|
793
|
+
try {
|
|
794
|
+
await access(join(process.cwd(), oldName));
|
|
795
|
+
log.warn(`Legacy directory detected: "${oldName}/" — rename it to "${newName}/" for the new convention.`);
|
|
796
|
+
} catch {
|
|
797
|
+
// does not exist — no warning needed
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
765
801
|
// Step 4b: Determine placement preferences (from config or prompt)
|
|
766
802
|
const placementPrefs = await resolvePlacementPreferences(appJson, options);
|
|
767
803
|
|
|
@@ -841,7 +877,7 @@ export async function performClone(source, options = {}) {
|
|
|
841
877
|
if (refs.length > 0) {
|
|
842
878
|
otherRefs[entityName] = refs;
|
|
843
879
|
}
|
|
844
|
-
} else if (
|
|
880
|
+
} else if (ENTITY_DIR_NAMES.has(entityName)) {
|
|
845
881
|
// Entity types with project directories — process into their directory
|
|
846
882
|
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
847
883
|
if (refs.length > 0) {
|
|
@@ -911,8 +947,14 @@ async function resolvePlacementPreferences(appJson, options) {
|
|
|
911
947
|
let contentPlacement = saved.contentPlacement;
|
|
912
948
|
let mediaPlacement = saved.mediaPlacement;
|
|
913
949
|
|
|
950
|
+
// --media-placement flag takes precedence over saved config
|
|
951
|
+
if (options.mediaPlacement) {
|
|
952
|
+
mediaPlacement = options.mediaPlacement === 'fullpath' ? 'fullpath' : 'bin';
|
|
953
|
+
await saveClonePlacement({ contentPlacement: contentPlacement || 'bin', mediaPlacement });
|
|
954
|
+
log.dim(` MediaPlacement set to "${mediaPlacement}" via flag`);
|
|
955
|
+
}
|
|
956
|
+
|
|
914
957
|
const hasContent = (appJson.children.content || []).length > 0;
|
|
915
|
-
const hasMedia = (appJson.children.media || []).length > 0;
|
|
916
958
|
|
|
917
959
|
// If -y flag, default to bin placement (no prompts)
|
|
918
960
|
if (options.yes) {
|
|
@@ -930,6 +972,7 @@ async function resolvePlacementPreferences(appJson, options) {
|
|
|
930
972
|
const inquirer = (await import('inquirer')).default;
|
|
931
973
|
const prompts = [];
|
|
932
974
|
|
|
975
|
+
// Only prompt for contentPlacement — media placement is NOT prompted interactively
|
|
933
976
|
if (!contentPlacement && hasContent) {
|
|
934
977
|
prompts.push({
|
|
935
978
|
type: 'list',
|
|
@@ -943,23 +986,14 @@ async function resolvePlacementPreferences(appJson, options) {
|
|
|
943
986
|
});
|
|
944
987
|
}
|
|
945
988
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
name: 'mediaPlacement',
|
|
950
|
-
message: 'How should media files be placed?',
|
|
951
|
-
choices: [
|
|
952
|
-
{ name: 'Save all in BinID directory', value: 'bin' },
|
|
953
|
-
{ name: 'Save all in their specified FullPath directory', value: 'fullpath' },
|
|
954
|
-
{ name: 'Ask for every file that has both', value: 'ask' },
|
|
955
|
-
],
|
|
956
|
-
});
|
|
989
|
+
// Media placement: no interactive prompt — default to 'bin'
|
|
990
|
+
if (!mediaPlacement) {
|
|
991
|
+
mediaPlacement = 'bin';
|
|
957
992
|
}
|
|
958
993
|
|
|
959
994
|
if (prompts.length > 0) {
|
|
960
995
|
const answers = await inquirer.prompt(prompts);
|
|
961
996
|
contentPlacement = contentPlacement || answers.contentPlacement || 'bin';
|
|
962
|
-
mediaPlacement = mediaPlacement || answers.mediaPlacement || 'bin';
|
|
963
997
|
}
|
|
964
998
|
|
|
965
999
|
// Resolve defaults for any still-unset values
|
|
@@ -1143,13 +1177,14 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
1143
1177
|
async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
1144
1178
|
if (!entries || entries.length === 0) return [];
|
|
1145
1179
|
|
|
1146
|
-
const dirName =
|
|
1147
|
-
if (!
|
|
1180
|
+
const dirName = entityName;
|
|
1181
|
+
if (!ENTITY_DIR_NAMES.has(entityName)) return [];
|
|
1148
1182
|
|
|
1149
1183
|
await mkdir(dirName, { recursive: true });
|
|
1150
1184
|
|
|
1151
1185
|
const refs = [];
|
|
1152
1186
|
const bulkAction = { value: null };
|
|
1187
|
+
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
1153
1188
|
const config = await loadConfig();
|
|
1154
1189
|
|
|
1155
1190
|
// Determine filename column: saved preference, or prompt, or default
|
|
@@ -1297,18 +1332,53 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1297
1332
|
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
1298
1333
|
}
|
|
1299
1334
|
|
|
1300
|
-
// Include UID in filename to ensure uniqueness
|
|
1301
|
-
// Convention: <name>.<UID>.metadata.json — unless name === UID, then just <UID>.metadata.json
|
|
1335
|
+
// Include UID in filename via tilde convention to ensure uniqueness
|
|
1302
1336
|
const uid = record.UID || 'untitled';
|
|
1303
|
-
const finalName = name
|
|
1337
|
+
const finalName = buildUidFilename(name, uid);
|
|
1304
1338
|
|
|
1305
1339
|
const metaPath = join(dirName, `${finalName}.metadata.json`);
|
|
1306
1340
|
|
|
1341
|
+
// Legacy dot-separator detection: rename <name>.<uid>.metadata.json → <name>~<uid>.metadata.json
|
|
1342
|
+
const legacyMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
|
|
1343
|
+
if (!await fileExists(metaPath) && await fileExists(legacyMetaPath)) {
|
|
1344
|
+
if (options.yes || legacyRenameAction.value === 'rename_all') {
|
|
1345
|
+
const { rename: fsRename } = await import('fs/promises');
|
|
1346
|
+
await fsRename(legacyMetaPath, metaPath);
|
|
1347
|
+
log.dim(` Auto-renamed: ${basename(legacyMetaPath)} → ${basename(metaPath)}`);
|
|
1348
|
+
} else if (legacyRenameAction.value === 'skip_all') {
|
|
1349
|
+
// skip silently
|
|
1350
|
+
} else {
|
|
1351
|
+
const inquirer = (await import('inquirer')).default;
|
|
1352
|
+
const { action } = await inquirer.prompt([{
|
|
1353
|
+
type: 'list',
|
|
1354
|
+
name: 'action',
|
|
1355
|
+
message: `Found legacy filename "${basename(legacyMetaPath)}" — rename to "${basename(metaPath)}"?`,
|
|
1356
|
+
choices: [
|
|
1357
|
+
{ name: 'Yes', value: 'rename' },
|
|
1358
|
+
{ name: 'Rename all remaining', value: 'rename_all' },
|
|
1359
|
+
{ name: 'Skip', value: 'skip' },
|
|
1360
|
+
{ name: 'Skip all remaining', value: 'skip_all' },
|
|
1361
|
+
],
|
|
1362
|
+
}]);
|
|
1363
|
+
if (action === 'rename' || action === 'rename_all') {
|
|
1364
|
+
const { rename: fsRename } = await import('fs/promises');
|
|
1365
|
+
await fsRename(legacyMetaPath, metaPath);
|
|
1366
|
+
log.success(` Renamed: ${basename(legacyMetaPath)} → ${basename(metaPath)}`);
|
|
1367
|
+
if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
|
|
1368
|
+
} else {
|
|
1369
|
+
if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1307
1374
|
// Change detection for existing files
|
|
1308
1375
|
// Skip change detection when user has selected new content columns to extract —
|
|
1309
1376
|
// we need to re-process all records to create the companion files
|
|
1310
1377
|
const hasNewExtractions = contentColsToExtract.length > 0;
|
|
1311
|
-
|
|
1378
|
+
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
1379
|
+
const willDeleteEntityMeta = join(dirName, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
|
|
1380
|
+
const entityMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteEntityMeta);
|
|
1381
|
+
if (entityMetaExists && !options.yes && !hasNewExtractions) {
|
|
1312
1382
|
if (bulkAction.value === 'skip_all') {
|
|
1313
1383
|
log.dim(` Skipped ${finalName}`);
|
|
1314
1384
|
refs.push({ uid: record.UID, metaPath });
|
|
@@ -1439,6 +1509,21 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1439
1509
|
refs.push({ uid: record.UID, metaPath });
|
|
1440
1510
|
}
|
|
1441
1511
|
|
|
1512
|
+
// --- Seed metadata template if not already present ---
|
|
1513
|
+
if (entries.length > 0) {
|
|
1514
|
+
const templates = await loadMetadataTemplates();
|
|
1515
|
+
if (templates !== null) {
|
|
1516
|
+
const existing = getTemplateCols(templates, entityName, null);
|
|
1517
|
+
if (!existing) {
|
|
1518
|
+
const firstRecord = entries[0];
|
|
1519
|
+
const extractedColNames = contentColsToExtract.map(c => c.col);
|
|
1520
|
+
const cols = buildTemplateFromCloneRecord(firstRecord, extractedColNames);
|
|
1521
|
+
setTemplateCols(templates, entityName, null, cols);
|
|
1522
|
+
await saveMetadataTemplates(templates);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1442
1527
|
return refs;
|
|
1443
1528
|
}
|
|
1444
1529
|
|
|
@@ -1614,8 +1699,8 @@ async function resolveDocumentationPlacement(options) {
|
|
|
1614
1699
|
type: 'list', name: 'placement',
|
|
1615
1700
|
message: 'Where should extracted documentation MD files be placed?',
|
|
1616
1701
|
choices: [
|
|
1617
|
-
{ name: '
|
|
1618
|
-
{ name: '
|
|
1702
|
+
{ name: 'docs/<filename>.md — project root (recommended)', value: 'root' },
|
|
1703
|
+
{ name: 'extension/documentation/<filename>.md — inline alongside metadata', value: 'inline' },
|
|
1619
1704
|
],
|
|
1620
1705
|
default: 'root',
|
|
1621
1706
|
}]);
|
|
@@ -1694,6 +1779,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1694
1779
|
// Step D: Write files, one group at a time
|
|
1695
1780
|
const refs = [];
|
|
1696
1781
|
const bulkAction = { value: null };
|
|
1782
|
+
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
1697
1783
|
const config = await loadConfig();
|
|
1698
1784
|
|
|
1699
1785
|
for (const [descriptor, { dir, records }] of groups.entries()) {
|
|
@@ -1715,12 +1801,48 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1715
1801
|
}
|
|
1716
1802
|
|
|
1717
1803
|
const uid = record.UID || 'untitled';
|
|
1718
|
-
const finalName = name
|
|
1804
|
+
const finalName = buildUidFilename(name, uid);
|
|
1719
1805
|
const metaPath = join(dir, `${finalName}.metadata.json`);
|
|
1720
1806
|
|
|
1807
|
+
// Legacy dot-separator detection: rename <name>.<uid>.metadata.json → <name>~<uid>.metadata.json
|
|
1808
|
+
const legacyExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
|
|
1809
|
+
if (!await fileExists(metaPath) && await fileExists(legacyExtMetaPath)) {
|
|
1810
|
+
if (options.yes || legacyRenameAction.value === 'rename_all') {
|
|
1811
|
+
const { rename: fsRename } = await import('fs/promises');
|
|
1812
|
+
await fsRename(legacyExtMetaPath, metaPath);
|
|
1813
|
+
log.dim(` Auto-renamed: ${basename(legacyExtMetaPath)} → ${basename(metaPath)}`);
|
|
1814
|
+
} else if (legacyRenameAction.value === 'skip_all') {
|
|
1815
|
+
// skip silently
|
|
1816
|
+
} else {
|
|
1817
|
+
const inquirer = (await import('inquirer')).default;
|
|
1818
|
+
const { action } = await inquirer.prompt([{
|
|
1819
|
+
type: 'list',
|
|
1820
|
+
name: 'action',
|
|
1821
|
+
message: `Found legacy filename "${basename(legacyExtMetaPath)}" — rename to "${basename(metaPath)}"?`,
|
|
1822
|
+
choices: [
|
|
1823
|
+
{ name: 'Yes', value: 'rename' },
|
|
1824
|
+
{ name: 'Rename all remaining', value: 'rename_all' },
|
|
1825
|
+
{ name: 'Skip', value: 'skip' },
|
|
1826
|
+
{ name: 'Skip all remaining', value: 'skip_all' },
|
|
1827
|
+
],
|
|
1828
|
+
}]);
|
|
1829
|
+
if (action === 'rename' || action === 'rename_all') {
|
|
1830
|
+
const { rename: fsRename } = await import('fs/promises');
|
|
1831
|
+
await fsRename(legacyExtMetaPath, metaPath);
|
|
1832
|
+
log.success(` Renamed: ${basename(legacyExtMetaPath)} → ${basename(metaPath)}`);
|
|
1833
|
+
if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
|
|
1834
|
+
} else {
|
|
1835
|
+
if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1721
1840
|
// Change detection — same pattern as processEntityDirEntries()
|
|
1722
1841
|
const hasNewExtractions = contentColsToExtract.length > 0;
|
|
1723
|
-
|
|
1842
|
+
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
1843
|
+
const willDeleteExtMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
|
|
1844
|
+
const extMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteExtMeta);
|
|
1845
|
+
if (extMetaExists && !options.yes && !hasNewExtractions) {
|
|
1724
1846
|
if (bulkAction.value === 'skip_all') {
|
|
1725
1847
|
log.dim(` Skipped ${finalName}`);
|
|
1726
1848
|
refs.push({ uid: record.UID, metaPath });
|
|
@@ -1770,8 +1892,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1770
1892
|
let colFilePath, refValue;
|
|
1771
1893
|
|
|
1772
1894
|
if (mdColInfo && extractInfo.col === mdColInfo.col) {
|
|
1773
|
-
// Root placement:
|
|
1774
|
-
const docFileName = `${
|
|
1895
|
+
// Root placement: docs/<name>~<uid>.md
|
|
1896
|
+
const docFileName = `${finalName}.md`;
|
|
1775
1897
|
colFilePath = join(DOCUMENTATION_DIR, docFileName);
|
|
1776
1898
|
refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
|
|
1777
1899
|
} else {
|
|
@@ -1809,6 +1931,21 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1809
1931
|
log.success(`Saved ${metaPath}`);
|
|
1810
1932
|
refs.push({ uid: record.UID, metaPath });
|
|
1811
1933
|
}
|
|
1934
|
+
|
|
1935
|
+
// --- Seed metadata template for this descriptor ---
|
|
1936
|
+
if (records.length > 0) {
|
|
1937
|
+
const templates = await loadMetadataTemplates();
|
|
1938
|
+
if (templates !== null) {
|
|
1939
|
+
const existing = getTemplateCols(templates, 'extension', descriptor);
|
|
1940
|
+
if (!existing) {
|
|
1941
|
+
const firstRecord = records[0];
|
|
1942
|
+
const extractedCols = contentColsToExtract.map(c => c.col);
|
|
1943
|
+
const cols = buildTemplateFromCloneRecord(firstRecord, extractedCols);
|
|
1944
|
+
setTemplateCols(templates, 'extension', descriptor, cols);
|
|
1945
|
+
await saveMetadataTemplates(templates);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1812
1949
|
}
|
|
1813
1950
|
|
|
1814
1951
|
return refs;
|
|
@@ -1936,27 +2073,21 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
1936
2073
|
if (!dir) dir = BINS_DIR;
|
|
1937
2074
|
await mkdir(dir, { recursive: true });
|
|
1938
2075
|
|
|
1939
|
-
//
|
|
1940
|
-
|
|
1941
|
-
const
|
|
1942
|
-
const
|
|
1943
|
-
usedNames.set(fileKey, fileCount + 1);
|
|
1944
|
-
let dedupName;
|
|
1945
|
-
if (fileCount > 0) {
|
|
1946
|
-
// Duplicate name — include UID for uniqueness
|
|
1947
|
-
const uid = record.UID || 'untitled';
|
|
1948
|
-
dedupName = name === uid ? uid : `${name}.${uid}`;
|
|
1949
|
-
} else {
|
|
1950
|
-
dedupName = name;
|
|
1951
|
-
}
|
|
1952
|
-
const finalFilename = `${dedupName}.${ext}`;
|
|
1953
|
-
// Metadata: use name.ext as base to avoid collisions between formats
|
|
1954
|
-
// e.g. KaTeX_SansSerif-Italic.woff.metadata.json vs KaTeX_SansSerif-Italic.woff2.metadata.json
|
|
2076
|
+
// Always include UID in filename via tilde convention
|
|
2077
|
+
const uid = String(record.UID || record._id || 'untitled');
|
|
2078
|
+
const base = buildUidFilename(name, uid);
|
|
2079
|
+
const finalFilename = `${base}.${ext}`;
|
|
1955
2080
|
const filePath = join(dir, finalFilename);
|
|
1956
2081
|
const metaPath = join(dir, `${finalFilename}.metadata.json`);
|
|
2082
|
+
// usedNames retained for tracking
|
|
2083
|
+
const fileKey = `${dir}/${name}.${ext}`;
|
|
2084
|
+
usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
|
|
1957
2085
|
|
|
1958
2086
|
// Change detection for existing media files
|
|
1959
|
-
|
|
2087
|
+
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
2088
|
+
const willDeleteMediaMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
|
|
2089
|
+
const mediaMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteMediaMeta);
|
|
2090
|
+
if (mediaMetaExists && !options.yes) {
|
|
1960
2091
|
if (mediaBulkAction.value === 'skip_all') {
|
|
1961
2092
|
log.dim(` Skipped ${finalFilename}`);
|
|
1962
2093
|
refs.push({ uid: record.UID, metaPath });
|
|
@@ -2266,18 +2397,12 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2266
2397
|
|
|
2267
2398
|
await mkdir(dir, { recursive: true });
|
|
2268
2399
|
|
|
2269
|
-
//
|
|
2400
|
+
// Always include UID in filename via tilde convention
|
|
2401
|
+
const uid = String(record.UID || record._id || 'untitled');
|
|
2402
|
+
const finalName = buildUidFilename(name, uid);
|
|
2403
|
+
// usedNames retained for non-UID edge case tracking
|
|
2270
2404
|
const nameKey = `${dir}/${name}`;
|
|
2271
|
-
|
|
2272
|
-
usedNames.set(nameKey, count + 1);
|
|
2273
|
-
let finalName;
|
|
2274
|
-
if (count > 0) {
|
|
2275
|
-
// Duplicate name — include UID for uniqueness
|
|
2276
|
-
const uid = record.UID || 'untitled';
|
|
2277
|
-
finalName = name === uid ? uid : `${name}.${uid}`;
|
|
2278
|
-
} else {
|
|
2279
|
-
finalName = name;
|
|
2280
|
-
}
|
|
2405
|
+
usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
|
|
2281
2406
|
|
|
2282
2407
|
// Write content file if Content column has data
|
|
2283
2408
|
const contentValue = record.Content;
|
|
@@ -2291,7 +2416,10 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2291
2416
|
const metaPath = join(dir, `${finalName}.metadata.json`);
|
|
2292
2417
|
|
|
2293
2418
|
// Change detection: check if file already exists locally
|
|
2294
|
-
|
|
2419
|
+
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
2420
|
+
const willDeleteMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
|
|
2421
|
+
const metaExistsForChangeDetect = await fileExists(metaPath) && !await fileExists(willDeleteMeta);
|
|
2422
|
+
if (metaExistsForChangeDetect && !options.yes) {
|
|
2295
2423
|
if (bulkAction.value === 'skip_all') {
|
|
2296
2424
|
log.dim(` Skipped ${finalName}.${ext}`);
|
|
2297
2425
|
return { uid: record.UID, metaPath };
|
|
@@ -2452,12 +2580,22 @@ export function guessExtensionForColumn(columnName) {
|
|
|
2452
2580
|
*/
|
|
2453
2581
|
export function buildOutputHierarchyTree(appJson) {
|
|
2454
2582
|
const outputs = appJson.children.output || [];
|
|
2455
|
-
const columns = appJson.children.output_value || [];
|
|
2456
|
-
const filters = appJson.children.output_value_filter || [];
|
|
2457
|
-
const joins = appJson.children.output_value_entity_column_rel || [];
|
|
2458
2583
|
|
|
2459
2584
|
if (outputs.length === 0) return [];
|
|
2460
2585
|
|
|
2586
|
+
// Collect columns/filters/joins from both top-level arrays (if present)
|
|
2587
|
+
// and from each output record's nested .children object.
|
|
2588
|
+
const columns = [...(appJson.children.output_value || [])];
|
|
2589
|
+
const filters = [...(appJson.children.output_value_filter || [])];
|
|
2590
|
+
const joins = [...(appJson.children.output_value_entity_column_rel || [])];
|
|
2591
|
+
|
|
2592
|
+
for (const o of outputs) {
|
|
2593
|
+
if (!o.children) continue;
|
|
2594
|
+
if (Array.isArray(o.children.output_value)) columns.push(...o.children.output_value);
|
|
2595
|
+
if (Array.isArray(o.children.output_value_filter)) filters.push(...o.children.output_value_filter);
|
|
2596
|
+
if (Array.isArray(o.children.output_value_entity_column_rel)) joins.push(...o.children.output_value_entity_column_rel);
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2461
2599
|
// Index all entities by their numeric ID for O(1) lookups
|
|
2462
2600
|
const outputById = new Map();
|
|
2463
2601
|
const columnById = new Map();
|
|
@@ -2579,7 +2717,17 @@ async function resolveOutputFilenameColumns(appJson, options) {
|
|
|
2579
2717
|
}
|
|
2580
2718
|
|
|
2581
2719
|
// Find a sample record to get available columns
|
|
2582
|
-
|
|
2720
|
+
// Check top-level first, then look inside nested output children
|
|
2721
|
+
let records = appJson.children[entityKey] || [];
|
|
2722
|
+
if (records.length === 0 && entityKey !== 'output') {
|
|
2723
|
+
const outputs = appJson.children.output || [];
|
|
2724
|
+
for (const o of outputs) {
|
|
2725
|
+
if (o.children && Array.isArray(o.children[entityKey]) && o.children[entityKey].length > 0) {
|
|
2726
|
+
records = o.children[entityKey];
|
|
2727
|
+
break;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2583
2731
|
if (records.length === 0) {
|
|
2584
2732
|
result[entityKey] = defaults[entityKey];
|
|
2585
2733
|
continue;
|
|
@@ -2903,18 +3051,24 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
2903
3051
|
for (const [key, value] of Object.entries(output)) {
|
|
2904
3052
|
if (key === '_children') continue;
|
|
2905
3053
|
|
|
2906
|
-
//
|
|
3054
|
+
// Extract CustomSQL to companion .sql file when Type is CustomSQL (even if empty)
|
|
3055
|
+
// or when the column has actual content
|
|
2907
3056
|
if (key === 'CustomSQL') {
|
|
2908
3057
|
const decoded = resolveContentValue(value);
|
|
2909
|
-
const
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
3058
|
+
const hasContent = decoded && decoded.trim();
|
|
3059
|
+
if (output.Type === 'CustomSQL' || hasContent) {
|
|
3060
|
+
const sqlFilePath = rootMetaPath.replace(/\.json$/, '.CustomSQL.sql');
|
|
3061
|
+
await writeFile(sqlFilePath, hasContent ? decoded : '');
|
|
3062
|
+
rootMeta[key] = `@${basename(sqlFilePath)}`;
|
|
3063
|
+
rootContentColumns.push('CustomSQL');
|
|
3064
|
+
if (serverTz && (output._CreatedOn || output._LastUpdated)) {
|
|
3065
|
+
try { await setFileTimestamps(sqlFilePath, output._CreatedOn, output._LastUpdated, serverTz); } catch { /* non-critical */ }
|
|
3066
|
+
}
|
|
3067
|
+
log.dim(` → ${sqlFilePath}`);
|
|
3068
|
+
continue;
|
|
2916
3069
|
}
|
|
2917
|
-
|
|
3070
|
+
// Not CustomSQL type and empty — store inline
|
|
3071
|
+
rootMeta[key] = '';
|
|
2918
3072
|
continue;
|
|
2919
3073
|
}
|
|
2920
3074
|
|
|
@@ -2964,21 +3118,27 @@ async function writeOutputEntityFile(node, physicalEntity, filePath, serverTz) {
|
|
|
2964
3118
|
for (const [key, value] of Object.entries(node)) {
|
|
2965
3119
|
if (key === '_children') continue;
|
|
2966
3120
|
|
|
2967
|
-
//
|
|
3121
|
+
// Extract CustomSQL to companion .sql file when Type is CustomSQL (even if empty)
|
|
3122
|
+
// or when the column has actual content
|
|
2968
3123
|
if (key === 'CustomSQL') {
|
|
2969
3124
|
const decoded = resolveContentValue(value);
|
|
2970
|
-
const
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
3125
|
+
const hasContent = decoded && decoded.trim();
|
|
3126
|
+
if (node.Type === 'CustomSQL' || hasContent) {
|
|
3127
|
+
const sqlFilePath = filePath.replace(/\.json$/, '.CustomSQL.sql');
|
|
3128
|
+
await writeFile(sqlFilePath, hasContent ? decoded : '');
|
|
3129
|
+
meta[key] = `@${basename(sqlFilePath)}`;
|
|
3130
|
+
contentColumns.push('CustomSQL');
|
|
3131
|
+
|
|
3132
|
+
if (serverTz && (node._CreatedOn || node._LastUpdated)) {
|
|
3133
|
+
try {
|
|
3134
|
+
await setFileTimestamps(sqlFilePath, node._CreatedOn, node._LastUpdated, serverTz);
|
|
3135
|
+
} catch { /* non-critical */ }
|
|
3136
|
+
}
|
|
3137
|
+
log.dim(` → ${sqlFilePath}`);
|
|
3138
|
+
continue;
|
|
2980
3139
|
}
|
|
2981
|
-
|
|
3140
|
+
// Not CustomSQL type and empty — store inline
|
|
3141
|
+
meta[key] = '';
|
|
2982
3142
|
continue;
|
|
2983
3143
|
}
|
|
2984
3144
|
|
package/src/commands/deploy.js
CHANGED
|
@@ -178,7 +178,7 @@ export const deployCommand = new Command('deploy')
|
|
|
178
178
|
log.success(`${entryName} deployed`);
|
|
179
179
|
} else {
|
|
180
180
|
log.error(`${entryName} failed`);
|
|
181
|
-
formatResponse(result, { json: options.json });
|
|
181
|
+
formatResponse(result, { json: options.json, verbose: options.verbose });
|
|
182
182
|
if (!options.all) process.exit(1);
|
|
183
183
|
}
|
|
184
184
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -20,8 +20,9 @@ export const initCommand = new Command('init')
|
|
|
20
20
|
.option('--non-interactive', 'Skip all interactive prompts')
|
|
21
21
|
.option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
|
|
22
22
|
.option('--local', 'Install Claude commands to project directory (.claude/commands/)')
|
|
23
|
-
.option('--scaffold', 'Create standard project directories (
|
|
23
|
+
.option('--scaffold', 'Create standard project directories (app_version, automation, bins, …)')
|
|
24
24
|
.option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
|
|
25
|
+
.option('--media-placement <placement>', 'Set media placement when cloning: fullpath or binpath (default: bin)')
|
|
25
26
|
.action(async (options) => {
|
|
26
27
|
// Merge --yes into nonInteractive
|
|
27
28
|
if (options.yes) options.nonInteractive = true;
|
|
@@ -98,7 +99,7 @@ export const initCommand = new Command('init')
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
// Ensure sensitive files are gitignored
|
|
101
|
-
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
|
|
102
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', 'trash/']);
|
|
102
103
|
|
|
103
104
|
const createdIgnore = await createDboignore();
|
|
104
105
|
if (createdIgnore) log.dim(' Created .dboignore');
|
|
@@ -186,7 +187,7 @@ export const initCommand = new Command('init')
|
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
const { performClone } = await import('./clone.js');
|
|
189
|
-
await performClone(null, { app: appShortName, domain });
|
|
190
|
+
await performClone(null, { app: appShortName, domain, mediaPlacement: options.mediaPlacement });
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
// Offer Claude Code integration (skip in non-interactive mode)
|
package/src/commands/input.js
CHANGED
|
@@ -124,7 +124,7 @@ export const inputCommand = new Command('input')
|
|
|
124
124
|
result = await client.postMultipart('/api/input/submit', fields, files);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
formatResponse(result, { json: options.json, jq: options.jq });
|
|
127
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
128
128
|
if (!result.successful) process.exit(1);
|
|
129
129
|
} else {
|
|
130
130
|
// URL-encoded mode
|
|
@@ -153,7 +153,7 @@ export const inputCommand = new Command('input')
|
|
|
153
153
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
formatResponse(result, { json: options.json, jq: options.jq });
|
|
156
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
157
157
|
if (!result.successful) process.exit(1);
|
|
158
158
|
}
|
|
159
159
|
} catch (err) {
|
package/src/commands/pull.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig } from '../lib/config.js';
|
|
|
5
5
|
import { formatError } from '../lib/formatter.js';
|
|
6
6
|
import { saveToDisk } from '../lib/save-to-disk.js';
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
|
+
import { renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
|
|
8
9
|
|
|
9
10
|
function collect(value, previous) {
|
|
10
11
|
return previous.concat([value]);
|