@dboio/cli 0.8.0 → 0.9.2
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 +157 -57
- package/package.json +1 -1
- package/src/commands/add.js +122 -10
- package/src/commands/clone.js +351 -99
- package/src/commands/deploy.js +1 -1
- package/src/commands/init.js +13 -4
- package/src/commands/input.js +2 -2
- package/src/commands/login.js +69 -0
- package/src/commands/pull.js +1 -0
- package/src/commands/push.js +202 -34
- package/src/commands/rm.js +48 -16
- package/src/lib/delta.js +14 -1
- package/src/lib/diff.js +4 -2
- 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 +79 -8
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 {
|
|
@@ -444,10 +457,22 @@ export const cloneCommand = new Command('clone')
|
|
|
444
457
|
async function resolveAppSource(source, options, config) {
|
|
445
458
|
if (source) {
|
|
446
459
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
460
|
+
const ora = (await import('ora')).default;
|
|
461
|
+
const spinner = ora(`Fetching app JSON from ${source}...`).start();
|
|
462
|
+
let res;
|
|
463
|
+
try {
|
|
464
|
+
res = await fetch(source);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
spinner.fail(`Failed to fetch from ${source}`);
|
|
467
|
+
throw err;
|
|
468
|
+
}
|
|
469
|
+
if (!res.ok) {
|
|
470
|
+
spinner.fail(`HTTP ${res.status} fetching ${source}`);
|
|
471
|
+
throw new Error(`HTTP ${res.status} fetching ${source}`);
|
|
472
|
+
}
|
|
473
|
+
const json = await res.json();
|
|
474
|
+
spinner.succeed('Loaded app JSON');
|
|
475
|
+
return json;
|
|
451
476
|
}
|
|
452
477
|
log.info(`Loading app JSON from ${source}...`);
|
|
453
478
|
const raw = await readFile(source, 'utf8');
|
|
@@ -460,10 +485,22 @@ async function resolveAppSource(source, options, config) {
|
|
|
460
485
|
if (storedSource && storedSource !== 'default') {
|
|
461
486
|
// Stored source is a local file path or URL — reuse it
|
|
462
487
|
if (storedSource.startsWith('http://') || storedSource.startsWith('https://')) {
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
488
|
+
const ora = (await import('ora')).default;
|
|
489
|
+
const spinner = ora(`Fetching app JSON from ${storedSource} (stored source)...`).start();
|
|
490
|
+
let res;
|
|
491
|
+
try {
|
|
492
|
+
res = await fetch(storedSource);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
spinner.fail(`Failed to fetch from ${storedSource}`);
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
if (!res.ok) {
|
|
498
|
+
spinner.fail(`HTTP ${res.status} fetching ${storedSource}`);
|
|
499
|
+
throw new Error(`HTTP ${res.status} fetching ${storedSource}`);
|
|
500
|
+
}
|
|
501
|
+
const json = await res.json();
|
|
502
|
+
spinner.succeed('Loaded app JSON');
|
|
503
|
+
return json;
|
|
467
504
|
}
|
|
468
505
|
if (await fileExists(storedSource)) {
|
|
469
506
|
log.info(`Loading app JSON from ${storedSource} (stored source)...`);
|
|
@@ -738,6 +775,29 @@ export async function performClone(source, options = {}) {
|
|
|
738
775
|
for (const d of DEFAULT_PROJECT_DIRS) log.dim(` ${d}/`);
|
|
739
776
|
for (const d of createdDirs) log.dim(` ${d}/`);
|
|
740
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
|
+
|
|
741
801
|
// Step 4b: Determine placement preferences (from config or prompt)
|
|
742
802
|
const placementPrefs = await resolvePlacementPreferences(appJson, options);
|
|
743
803
|
|
|
@@ -817,7 +877,7 @@ export async function performClone(source, options = {}) {
|
|
|
817
877
|
if (refs.length > 0) {
|
|
818
878
|
otherRefs[entityName] = refs;
|
|
819
879
|
}
|
|
820
|
-
} else if (
|
|
880
|
+
} else if (ENTITY_DIR_NAMES.has(entityName)) {
|
|
821
881
|
// Entity types with project directories — process into their directory
|
|
822
882
|
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
823
883
|
if (refs.length > 0) {
|
|
@@ -887,8 +947,14 @@ async function resolvePlacementPreferences(appJson, options) {
|
|
|
887
947
|
let contentPlacement = saved.contentPlacement;
|
|
888
948
|
let mediaPlacement = saved.mediaPlacement;
|
|
889
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
|
+
|
|
890
957
|
const hasContent = (appJson.children.content || []).length > 0;
|
|
891
|
-
const hasMedia = (appJson.children.media || []).length > 0;
|
|
892
958
|
|
|
893
959
|
// If -y flag, default to bin placement (no prompts)
|
|
894
960
|
if (options.yes) {
|
|
@@ -906,6 +972,7 @@ async function resolvePlacementPreferences(appJson, options) {
|
|
|
906
972
|
const inquirer = (await import('inquirer')).default;
|
|
907
973
|
const prompts = [];
|
|
908
974
|
|
|
975
|
+
// Only prompt for contentPlacement — media placement is NOT prompted interactively
|
|
909
976
|
if (!contentPlacement && hasContent) {
|
|
910
977
|
prompts.push({
|
|
911
978
|
type: 'list',
|
|
@@ -919,23 +986,14 @@ async function resolvePlacementPreferences(appJson, options) {
|
|
|
919
986
|
});
|
|
920
987
|
}
|
|
921
988
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
name: 'mediaPlacement',
|
|
926
|
-
message: 'How should media files be placed?',
|
|
927
|
-
choices: [
|
|
928
|
-
{ name: 'Save all in BinID directory', value: 'bin' },
|
|
929
|
-
{ name: 'Save all in their specified FullPath directory', value: 'fullpath' },
|
|
930
|
-
{ name: 'Ask for every file that has both', value: 'ask' },
|
|
931
|
-
],
|
|
932
|
-
});
|
|
989
|
+
// Media placement: no interactive prompt — default to 'bin'
|
|
990
|
+
if (!mediaPlacement) {
|
|
991
|
+
mediaPlacement = 'bin';
|
|
933
992
|
}
|
|
934
993
|
|
|
935
994
|
if (prompts.length > 0) {
|
|
936
995
|
const answers = await inquirer.prompt(prompts);
|
|
937
996
|
contentPlacement = contentPlacement || answers.contentPlacement || 'bin';
|
|
938
|
-
mediaPlacement = mediaPlacement || answers.mediaPlacement || 'bin';
|
|
939
997
|
}
|
|
940
998
|
|
|
941
999
|
// Resolve defaults for any still-unset values
|
|
@@ -960,19 +1018,45 @@ async function resolvePlacementPreferences(appJson, options) {
|
|
|
960
1018
|
*/
|
|
961
1019
|
async function fetchAppFromServer(appShortName, options, config) {
|
|
962
1020
|
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
963
|
-
log.info(`Fetching app "${appShortName}" from server...`);
|
|
964
1021
|
|
|
965
|
-
const
|
|
1022
|
+
const ora = (await import('ora')).default;
|
|
1023
|
+
const spinner = ora(`Fetching app "${appShortName}" from server...`).start();
|
|
1024
|
+
|
|
1025
|
+
let result;
|
|
1026
|
+
try {
|
|
1027
|
+
result = await client.get(`/api/app/object/${appShortName}`);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
spinner.fail(`Failed to fetch app "${appShortName}"`);
|
|
1030
|
+
throw err;
|
|
1031
|
+
}
|
|
966
1032
|
|
|
967
1033
|
const data = result.payload || result.data;
|
|
968
|
-
const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || []);
|
|
969
1034
|
|
|
970
|
-
|
|
1035
|
+
// Handle all response shapes:
|
|
1036
|
+
// 1. Array of rows: [{ UID, ShortName, ... }]
|
|
1037
|
+
// 2. Object with Rows key: { Rows: [...] }
|
|
1038
|
+
// 3. Single app object: { UID, ShortName, children, ... }
|
|
1039
|
+
let appRecord;
|
|
1040
|
+
if (Array.isArray(data)) {
|
|
1041
|
+
appRecord = data.length > 0 ? data[0] : null;
|
|
1042
|
+
} else if (data?.Rows?.length > 0) {
|
|
1043
|
+
appRecord = data.Rows[0];
|
|
1044
|
+
} else if (data?.rows?.length > 0) {
|
|
1045
|
+
appRecord = data.rows[0];
|
|
1046
|
+
} else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
|
|
1047
|
+
// Single app object returned directly as payload
|
|
1048
|
+
appRecord = data;
|
|
1049
|
+
} else {
|
|
1050
|
+
appRecord = null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (!appRecord) {
|
|
1054
|
+
spinner.fail(`No app found with ShortName "${appShortName}"`);
|
|
971
1055
|
throw new Error(`No app found with ShortName "${appShortName}"`);
|
|
972
1056
|
}
|
|
973
1057
|
|
|
974
|
-
|
|
975
|
-
return
|
|
1058
|
+
spinner.succeed(`Found app on server`);
|
|
1059
|
+
return appRecord;
|
|
976
1060
|
}
|
|
977
1061
|
|
|
978
1062
|
/**
|
|
@@ -1093,13 +1177,14 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
1093
1177
|
async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
1094
1178
|
if (!entries || entries.length === 0) return [];
|
|
1095
1179
|
|
|
1096
|
-
const dirName =
|
|
1097
|
-
if (!
|
|
1180
|
+
const dirName = entityName;
|
|
1181
|
+
if (!ENTITY_DIR_NAMES.has(entityName)) return [];
|
|
1098
1182
|
|
|
1099
1183
|
await mkdir(dirName, { recursive: true });
|
|
1100
1184
|
|
|
1101
1185
|
const refs = [];
|
|
1102
1186
|
const bulkAction = { value: null };
|
|
1187
|
+
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
1103
1188
|
const config = await loadConfig();
|
|
1104
1189
|
|
|
1105
1190
|
// Determine filename column: saved preference, or prompt, or default
|
|
@@ -1247,18 +1332,53 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1247
1332
|
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
1248
1333
|
}
|
|
1249
1334
|
|
|
1250
|
-
// Include UID in filename to ensure uniqueness
|
|
1251
|
-
// Convention: <name>.<UID>.metadata.json — unless name === UID, then just <UID>.metadata.json
|
|
1335
|
+
// Include UID in filename via tilde convention to ensure uniqueness
|
|
1252
1336
|
const uid = record.UID || 'untitled';
|
|
1253
|
-
const finalName = name
|
|
1337
|
+
const finalName = buildUidFilename(name, uid);
|
|
1254
1338
|
|
|
1255
1339
|
const metaPath = join(dirName, `${finalName}.metadata.json`);
|
|
1256
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
|
+
|
|
1257
1374
|
// Change detection for existing files
|
|
1258
1375
|
// Skip change detection when user has selected new content columns to extract —
|
|
1259
1376
|
// we need to re-process all records to create the companion files
|
|
1260
1377
|
const hasNewExtractions = contentColsToExtract.length > 0;
|
|
1261
|
-
|
|
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) {
|
|
1262
1382
|
if (bulkAction.value === 'skip_all') {
|
|
1263
1383
|
log.dim(` Skipped ${finalName}`);
|
|
1264
1384
|
refs.push({ uid: record.UID, metaPath });
|
|
@@ -1389,6 +1509,21 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1389
1509
|
refs.push({ uid: record.UID, metaPath });
|
|
1390
1510
|
}
|
|
1391
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
|
+
|
|
1392
1527
|
return refs;
|
|
1393
1528
|
}
|
|
1394
1529
|
|
|
@@ -1564,8 +1699,8 @@ async function resolveDocumentationPlacement(options) {
|
|
|
1564
1699
|
type: 'list', name: 'placement',
|
|
1565
1700
|
message: 'Where should extracted documentation MD files be placed?',
|
|
1566
1701
|
choices: [
|
|
1567
|
-
{ name: '
|
|
1568
|
-
{ name: '
|
|
1702
|
+
{ name: 'docs/<filename>.md — project root (recommended)', value: 'root' },
|
|
1703
|
+
{ name: 'extension/documentation/<filename>.md — inline alongside metadata', value: 'inline' },
|
|
1569
1704
|
],
|
|
1570
1705
|
default: 'root',
|
|
1571
1706
|
}]);
|
|
@@ -1644,6 +1779,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1644
1779
|
// Step D: Write files, one group at a time
|
|
1645
1780
|
const refs = [];
|
|
1646
1781
|
const bulkAction = { value: null };
|
|
1782
|
+
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
1647
1783
|
const config = await loadConfig();
|
|
1648
1784
|
|
|
1649
1785
|
for (const [descriptor, { dir, records }] of groups.entries()) {
|
|
@@ -1665,12 +1801,48 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1665
1801
|
}
|
|
1666
1802
|
|
|
1667
1803
|
const uid = record.UID || 'untitled';
|
|
1668
|
-
const finalName = name
|
|
1804
|
+
const finalName = buildUidFilename(name, uid);
|
|
1669
1805
|
const metaPath = join(dir, `${finalName}.metadata.json`);
|
|
1670
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
|
+
|
|
1671
1840
|
// Change detection — same pattern as processEntityDirEntries()
|
|
1672
1841
|
const hasNewExtractions = contentColsToExtract.length > 0;
|
|
1673
|
-
|
|
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) {
|
|
1674
1846
|
if (bulkAction.value === 'skip_all') {
|
|
1675
1847
|
log.dim(` Skipped ${finalName}`);
|
|
1676
1848
|
refs.push({ uid: record.UID, metaPath });
|
|
@@ -1720,8 +1892,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1720
1892
|
let colFilePath, refValue;
|
|
1721
1893
|
|
|
1722
1894
|
if (mdColInfo && extractInfo.col === mdColInfo.col) {
|
|
1723
|
-
// Root placement:
|
|
1724
|
-
const docFileName = `${
|
|
1895
|
+
// Root placement: docs/<name>~<uid>.md
|
|
1896
|
+
const docFileName = `${finalName}.md`;
|
|
1725
1897
|
colFilePath = join(DOCUMENTATION_DIR, docFileName);
|
|
1726
1898
|
refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
|
|
1727
1899
|
} else {
|
|
@@ -1759,6 +1931,21 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1759
1931
|
log.success(`Saved ${metaPath}`);
|
|
1760
1932
|
refs.push({ uid: record.UID, metaPath });
|
|
1761
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
|
+
}
|
|
1762
1949
|
}
|
|
1763
1950
|
|
|
1764
1951
|
return refs;
|
|
@@ -1886,27 +2073,21 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
1886
2073
|
if (!dir) dir = BINS_DIR;
|
|
1887
2074
|
await mkdir(dir, { recursive: true });
|
|
1888
2075
|
|
|
1889
|
-
//
|
|
1890
|
-
|
|
1891
|
-
const
|
|
1892
|
-
const
|
|
1893
|
-
usedNames.set(fileKey, fileCount + 1);
|
|
1894
|
-
let dedupName;
|
|
1895
|
-
if (fileCount > 0) {
|
|
1896
|
-
// Duplicate name — include UID for uniqueness
|
|
1897
|
-
const uid = record.UID || 'untitled';
|
|
1898
|
-
dedupName = name === uid ? uid : `${name}.${uid}`;
|
|
1899
|
-
} else {
|
|
1900
|
-
dedupName = name;
|
|
1901
|
-
}
|
|
1902
|
-
const finalFilename = `${dedupName}.${ext}`;
|
|
1903
|
-
// Metadata: use name.ext as base to avoid collisions between formats
|
|
1904
|
-
// 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}`;
|
|
1905
2080
|
const filePath = join(dir, finalFilename);
|
|
1906
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);
|
|
1907
2085
|
|
|
1908
2086
|
// Change detection for existing media files
|
|
1909
|
-
|
|
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) {
|
|
1910
2091
|
if (mediaBulkAction.value === 'skip_all') {
|
|
1911
2092
|
log.dim(` Skipped ${finalFilename}`);
|
|
1912
2093
|
refs.push({ uid: record.UID, metaPath });
|
|
@@ -2114,6 +2295,41 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2114
2295
|
}
|
|
2115
2296
|
// If still no extension, ext remains '' (no extension)
|
|
2116
2297
|
|
|
2298
|
+
// If no extension determined and Content column has data, prompt user to choose one
|
|
2299
|
+
if (!ext && !options.yes && record.Content) {
|
|
2300
|
+
const cv = record.Content;
|
|
2301
|
+
const hasContentData = cv && (
|
|
2302
|
+
(typeof cv === 'object' && cv.value !== null && cv.value !== undefined) ||
|
|
2303
|
+
(typeof cv === 'string' && cv.length > 0)
|
|
2304
|
+
);
|
|
2305
|
+
if (hasContentData) {
|
|
2306
|
+
// Decode a snippet for preview
|
|
2307
|
+
let snippet = '';
|
|
2308
|
+
try {
|
|
2309
|
+
const decoded = resolveContentValue(cv);
|
|
2310
|
+
if (decoded) {
|
|
2311
|
+
snippet = decoded.substring(0, 80).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
2312
|
+
if (decoded.length > 80) snippet += '...';
|
|
2313
|
+
}
|
|
2314
|
+
} catch { /* ignore decode errors */ }
|
|
2315
|
+
const preview = snippet ? ` (${snippet})` : '';
|
|
2316
|
+
const VALID_CONTENT_EXTENSIONS = ['css', 'js', 'html', 'xml', 'txt', 'md', 'cs', 'json', 'sql'];
|
|
2317
|
+
const inquirer = (await import('inquirer')).default;
|
|
2318
|
+
const { chosenExt } = await inquirer.prompt([{
|
|
2319
|
+
type: 'list',
|
|
2320
|
+
name: 'chosenExt',
|
|
2321
|
+
message: `No extension found for "${record.Name || record.UID}". Choose a file extension for the Content:${preview}`,
|
|
2322
|
+
choices: [
|
|
2323
|
+
...VALID_CONTENT_EXTENSIONS.map(e => ({ name: `.${e}`, value: e })),
|
|
2324
|
+
{ name: 'No extension (skip)', value: '' },
|
|
2325
|
+
],
|
|
2326
|
+
}]);
|
|
2327
|
+
if (chosenExt) {
|
|
2328
|
+
ext = chosenExt;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2117
2333
|
// Avoid double extension: if name already ends with .ext, strip it
|
|
2118
2334
|
if (ext) {
|
|
2119
2335
|
const extWithDot = `.${ext}`;
|
|
@@ -2181,18 +2397,12 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2181
2397
|
|
|
2182
2398
|
await mkdir(dir, { recursive: true });
|
|
2183
2399
|
|
|
2184
|
-
//
|
|
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
|
|
2185
2404
|
const nameKey = `${dir}/${name}`;
|
|
2186
|
-
|
|
2187
|
-
usedNames.set(nameKey, count + 1);
|
|
2188
|
-
let finalName;
|
|
2189
|
-
if (count > 0) {
|
|
2190
|
-
// Duplicate name — include UID for uniqueness
|
|
2191
|
-
const uid = record.UID || 'untitled';
|
|
2192
|
-
finalName = name === uid ? uid : `${name}.${uid}`;
|
|
2193
|
-
} else {
|
|
2194
|
-
finalName = name;
|
|
2195
|
-
}
|
|
2405
|
+
usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
|
|
2196
2406
|
|
|
2197
2407
|
// Write content file if Content column has data
|
|
2198
2408
|
const contentValue = record.Content;
|
|
@@ -2206,7 +2416,10 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2206
2416
|
const metaPath = join(dir, `${finalName}.metadata.json`);
|
|
2207
2417
|
|
|
2208
2418
|
// Change detection: check if file already exists locally
|
|
2209
|
-
|
|
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) {
|
|
2210
2423
|
if (bulkAction.value === 'skip_all') {
|
|
2211
2424
|
log.dim(` Skipped ${finalName}.${ext}`);
|
|
2212
2425
|
return { uid: record.UID, metaPath };
|
|
@@ -2320,6 +2533,13 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2320
2533
|
meta._contentColumns = ['Content'];
|
|
2321
2534
|
}
|
|
2322
2535
|
|
|
2536
|
+
// If the extension picker chose an extension (record.Extension was null),
|
|
2537
|
+
// set it in metadata only — not in the record — so the baseline preserves
|
|
2538
|
+
// the server's null and push detects the change.
|
|
2539
|
+
if (ext && !record.Extension) {
|
|
2540
|
+
meta.Extension = ext;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2323
2543
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
2324
2544
|
log.dim(` → ${metaPath}`);
|
|
2325
2545
|
|
|
@@ -2360,12 +2580,22 @@ export function guessExtensionForColumn(columnName) {
|
|
|
2360
2580
|
*/
|
|
2361
2581
|
export function buildOutputHierarchyTree(appJson) {
|
|
2362
2582
|
const outputs = appJson.children.output || [];
|
|
2363
|
-
const columns = appJson.children.output_value || [];
|
|
2364
|
-
const filters = appJson.children.output_value_filter || [];
|
|
2365
|
-
const joins = appJson.children.output_value_entity_column_rel || [];
|
|
2366
2583
|
|
|
2367
2584
|
if (outputs.length === 0) return [];
|
|
2368
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
|
+
|
|
2369
2599
|
// Index all entities by their numeric ID for O(1) lookups
|
|
2370
2600
|
const outputById = new Map();
|
|
2371
2601
|
const columnById = new Map();
|
|
@@ -2487,7 +2717,17 @@ async function resolveOutputFilenameColumns(appJson, options) {
|
|
|
2487
2717
|
}
|
|
2488
2718
|
|
|
2489
2719
|
// Find a sample record to get available columns
|
|
2490
|
-
|
|
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
|
+
}
|
|
2491
2731
|
if (records.length === 0) {
|
|
2492
2732
|
result[entityKey] = defaults[entityKey];
|
|
2493
2733
|
continue;
|
|
@@ -2811,18 +3051,24 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
2811
3051
|
for (const [key, value] of Object.entries(output)) {
|
|
2812
3052
|
if (key === '_children') continue;
|
|
2813
3053
|
|
|
2814
|
-
//
|
|
3054
|
+
// Extract CustomSQL to companion .sql file when Type is CustomSQL (even if empty)
|
|
3055
|
+
// or when the column has actual content
|
|
2815
3056
|
if (key === 'CustomSQL') {
|
|
2816
3057
|
const decoded = resolveContentValue(value);
|
|
2817
|
-
const
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
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;
|
|
2824
3069
|
}
|
|
2825
|
-
|
|
3070
|
+
// Not CustomSQL type and empty — store inline
|
|
3071
|
+
rootMeta[key] = '';
|
|
2826
3072
|
continue;
|
|
2827
3073
|
}
|
|
2828
3074
|
|
|
@@ -2872,21 +3118,27 @@ async function writeOutputEntityFile(node, physicalEntity, filePath, serverTz) {
|
|
|
2872
3118
|
for (const [key, value] of Object.entries(node)) {
|
|
2873
3119
|
if (key === '_children') continue;
|
|
2874
3120
|
|
|
2875
|
-
//
|
|
3121
|
+
// Extract CustomSQL to companion .sql file when Type is CustomSQL (even if empty)
|
|
3122
|
+
// or when the column has actual content
|
|
2876
3123
|
if (key === 'CustomSQL') {
|
|
2877
3124
|
const decoded = resolveContentValue(value);
|
|
2878
|
-
const
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
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;
|
|
2888
3139
|
}
|
|
2889
|
-
|
|
3140
|
+
// Not CustomSQL type and empty — store inline
|
|
3141
|
+
meta[key] = '';
|
|
2890
3142
|
continue;
|
|
2891
3143
|
}
|
|
2892
3144
|
|