@dboio/cli 0.17.0 → 0.19.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 +111 -85
- package/package.json +1 -1
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +111 -85
- package/src/commands/build.js +3 -3
- package/src/commands/clone.js +236 -97
- package/src/commands/deploy.js +3 -3
- package/src/commands/init.js +11 -11
- package/src/commands/install.js +3 -3
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +15 -15
- package/src/commands/pull.js +1 -1
- package/src/commands/push.js +193 -14
- package/src/commands/rm.js +2 -2
- package/src/commands/run.js +4 -4
- package/src/commands/status.js +1 -1
- package/src/commands/sync.js +2 -2
- package/src/lib/config.js +186 -135
- package/src/lib/delta.js +119 -17
- package/src/lib/dependencies.js +51 -24
- package/src/lib/deploy-config.js +4 -4
- package/src/lib/domain-guard.js +8 -9
- package/src/lib/filenames.js +12 -1
- package/src/lib/ignore.js +2 -3
- package/src/lib/insert.js +1 -1
- package/src/lib/metadata-schema.js +14 -20
- package/src/lib/metadata-templates.js +4 -4
- package/src/lib/migrations.js +1 -1
- package/src/lib/modify-key.js +1 -1
- package/src/lib/scaffold.js +5 -12
- package/src/lib/schema.js +67 -37
- package/src/lib/structure.js +6 -6
- package/src/lib/tagging.js +2 -2
- package/src/lib/ticketing.js +3 -7
- package/src/lib/toe-stepping.js +5 -5
- package/src/lib/transaction-key.js +1 -1
- package/src/migrations/004-rename-output-files.js +2 -2
- package/src/migrations/005-rename-output-metadata.js +2 -2
- package/src/migrations/006-remove-uid-companion-filenames.js +1 -1
- package/src/migrations/007-natural-entity-companion-filenames.js +1 -1
- package/src/migrations/008-metadata-uid-in-suffix.js +1 -1
- package/src/migrations/009-fix-media-collision-metadata-names.js +1 -1
- package/src/migrations/010-delete-paren-media-orphans.js +1 -1
- package/src/migrations/012-project-dir-restructure.js +211 -0
package/src/commands/clone.js
CHANGED
|
@@ -13,10 +13,17 @@ import { loadIgnore } from '../lib/ignore.js';
|
|
|
13
13
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
14
14
|
import { applyTrashIcon, ensureTrashIcon, tagProjectFiles } from '../lib/tagging.js';
|
|
15
15
|
import { loadMetadataSchema, saveMetadataSchema, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord, generateMetadataFromSchema, parseReferenceExpression, mergeDescriptorSchemaFromDependencies } from '../lib/metadata-schema.js';
|
|
16
|
-
import { fetchSchema, loadSchema, saveSchema, isSchemaStale
|
|
16
|
+
import { fetchSchema, loadSchema, saveSchema, isSchemaStale } from '../lib/schema.js';
|
|
17
|
+
import { appMetadataPath } from '../lib/config.js';
|
|
17
18
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
18
19
|
import { upsertDeployEntry } from '../lib/deploy-config.js';
|
|
19
20
|
import { syncDependencies, parseDependenciesColumn } from '../lib/dependencies.js';
|
|
21
|
+
import { sep } from 'path';
|
|
22
|
+
|
|
23
|
+
/** True when cwd is inside app_dependencies/ (dependency checkout clone). */
|
|
24
|
+
function isDependencyCheckout() {
|
|
25
|
+
return process.cwd().includes(`${sep}app_dependencies${sep}`);
|
|
26
|
+
}
|
|
20
27
|
import { mergeDependencies } from '../lib/config.js';
|
|
21
28
|
|
|
22
29
|
/**
|
|
@@ -34,6 +41,32 @@ export function resolveContentValue(value) {
|
|
|
34
41
|
return value !== null && value !== undefined ? String(value) : null;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Embed a server children object into metadata, decoding any base64 field values.
|
|
46
|
+
* Used by processEntityDirEntries() and processExtensionEntries().
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} childrenObj - Server-side children: { entity_column: [...], ... }
|
|
49
|
+
* @returns {Object} - Decoded children object safe to write to metadata
|
|
50
|
+
*/
|
|
51
|
+
export function embedEntityChildren(childrenObj) {
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const [childEntityName, childArray] of Object.entries(childrenObj)) {
|
|
54
|
+
if (!Array.isArray(childArray)) continue;
|
|
55
|
+
result[childEntityName] = childArray.map(child => {
|
|
56
|
+
const embedded = {};
|
|
57
|
+
for (const [k, v] of Object.entries(child)) {
|
|
58
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && v.encoding === 'base64') {
|
|
59
|
+
embedded[k] = resolveContentValue(v) ?? '';
|
|
60
|
+
} else {
|
|
61
|
+
embedded[k] = v;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return embedded;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
37
70
|
export function sanitizeFilename(name) {
|
|
38
71
|
return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-').substring(0, 200);
|
|
39
72
|
}
|
|
@@ -491,6 +524,49 @@ async function detectAndRenameLegacyCompanions(metaPath, meta) {
|
|
|
491
524
|
return metaChanged;
|
|
492
525
|
}
|
|
493
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Clean up double/triple-metadata files (e.g., "app.metadata.metadata~uid.json")
|
|
529
|
+
* caused by an older bug where buildMetaFilename received a base already containing ".metadata".
|
|
530
|
+
* Scans lib/ directories for files matching the pattern and removes them.
|
|
531
|
+
*/
|
|
532
|
+
async function cleanDoubleMetadataFiles() {
|
|
533
|
+
const libDir = join(process.cwd(), 'lib');
|
|
534
|
+
if (!await fileExists(libDir)) return;
|
|
535
|
+
|
|
536
|
+
const trashDir = join(process.cwd(), 'trash');
|
|
537
|
+
let cleaned = 0;
|
|
538
|
+
|
|
539
|
+
async function scan(dir) {
|
|
540
|
+
let entries;
|
|
541
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
542
|
+
|
|
543
|
+
for (const entry of entries) {
|
|
544
|
+
if (entry.name.startsWith('.')) continue;
|
|
545
|
+
const full = join(dir, entry.name);
|
|
546
|
+
|
|
547
|
+
if (entry.isDirectory()) {
|
|
548
|
+
if (['node_modules', 'trash', '.git', '.app', 'app_dependencies'].includes(entry.name)) continue;
|
|
549
|
+
await scan(full);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Detect double+ .metadata pattern: "name.metadata.metadata~uid.json" or "name.metadata.metadata.metadata~uid.json"
|
|
554
|
+
if (/\.metadata\.metadata[.~]/.test(entry.name)) {
|
|
555
|
+
try {
|
|
556
|
+
await mkdir(trashDir, { recursive: true });
|
|
557
|
+
await rename(full, join(trashDir, entry.name));
|
|
558
|
+
cleaned++;
|
|
559
|
+
} catch { /* non-critical */ }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
await scan(libDir);
|
|
565
|
+
if (cleaned > 0) {
|
|
566
|
+
log.dim(` Cleaned ${cleaned} duplicate metadata file(s) → trash/`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
494
570
|
/**
|
|
495
571
|
* Scan all directories under Bins/ for orphaned legacy ~UID companion files
|
|
496
572
|
* that no metadata @reference points to, and move them to trash/.
|
|
@@ -793,7 +869,7 @@ async function stageCollisionDeletions(toDelete, appJson, options) {
|
|
|
793
869
|
}
|
|
794
870
|
|
|
795
871
|
if (staged > 0) {
|
|
796
|
-
log.success(`${staged} record(s) staged in .
|
|
872
|
+
log.success(`${staged} record(s) staged in .app/synchronize.json`);
|
|
797
873
|
log.dim(' Run "dbo push" to delete from server');
|
|
798
874
|
}
|
|
799
875
|
}
|
|
@@ -935,7 +1011,7 @@ async function checkPendingSynchronize(options) {
|
|
|
935
1011
|
if (totalCount === 0) return;
|
|
936
1012
|
|
|
937
1013
|
log.warn('');
|
|
938
|
-
log.warn(` ⚠ There are ${totalCount} un-pushed staged item(s) in .
|
|
1014
|
+
log.warn(` ⚠ There are ${totalCount} un-pushed staged item(s) in .app/synchronize.json:`);
|
|
939
1015
|
if (deleteCount > 0) log.warn(` ${deleteCount} pending deletion(s)`);
|
|
940
1016
|
if (editCount > 0) log.warn(` ${editCount} pending edit(s)`);
|
|
941
1017
|
if (addCount > 0) log.warn(` ${addCount} pending add(s)`);
|
|
@@ -1019,34 +1095,8 @@ export async function performClone(source, options = {}) {
|
|
|
1019
1095
|
const effectiveDomain = options.domain || config.domain;
|
|
1020
1096
|
let appJson;
|
|
1021
1097
|
|
|
1022
|
-
//
|
|
1098
|
+
// Load local schema (server fetch deferred until after auth validation)
|
|
1023
1099
|
let schema = await loadSchema();
|
|
1024
|
-
let shouldFetch = !schema || options.schema;
|
|
1025
|
-
if (!shouldFetch && schema) {
|
|
1026
|
-
try {
|
|
1027
|
-
shouldFetch = await isSchemaStale({ domain: effectiveDomain, verbose: options.verbose });
|
|
1028
|
-
if (shouldFetch) log.dim(` Server schema is newer — refreshing schema.json`);
|
|
1029
|
-
} catch {
|
|
1030
|
-
// Can't check — continue with local schema
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
if (shouldFetch) {
|
|
1034
|
-
try {
|
|
1035
|
-
schema = await fetchSchema({ domain: effectiveDomain, verbose: options.verbose });
|
|
1036
|
-
await saveSchema(schema);
|
|
1037
|
-
log.dim(` Saved schema.json`);
|
|
1038
|
-
} catch (err) {
|
|
1039
|
-
if (!schema) log.warn(` Could not fetch schema: ${err.message}`);
|
|
1040
|
-
// Continue with stale schema or null
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// Regenerate metadata_schema.json for any new entity types
|
|
1045
|
-
if (schema) {
|
|
1046
|
-
const existing = await loadMetadataSchema();
|
|
1047
|
-
const updated = generateMetadataFromSchema(schema, existing ?? {});
|
|
1048
|
-
await saveMetadataSchema(updated);
|
|
1049
|
-
}
|
|
1050
1100
|
|
|
1051
1101
|
// Step 1: Source mismatch detection (skip in pull mode)
|
|
1052
1102
|
// Warn when the user provides an explicit source that differs from the stored one.
|
|
@@ -1072,7 +1122,9 @@ export async function performClone(source, options = {}) {
|
|
|
1072
1122
|
}
|
|
1073
1123
|
}
|
|
1074
1124
|
|
|
1075
|
-
// Step 2: Load the app JSON — retry loop with fallback prompt on failure
|
|
1125
|
+
// Step 2: Load the app JSON — retry loop with fallback prompt on failure.
|
|
1126
|
+
// This runs BEFORE schema/dependency sync so that the login prompt fires
|
|
1127
|
+
// here if the session is expired (not buried inside a silent dependency clone).
|
|
1076
1128
|
let activeSource = source;
|
|
1077
1129
|
while (true) {
|
|
1078
1130
|
try {
|
|
@@ -1102,6 +1154,35 @@ export async function performClone(source, options = {}) {
|
|
|
1102
1154
|
throw new Error('Invalid app JSON: missing UID or children');
|
|
1103
1155
|
}
|
|
1104
1156
|
|
|
1157
|
+
// Fetch schema if missing, explicitly requested, or server has a newer version.
|
|
1158
|
+
// Runs AFTER app fetch so that login prompt fires first on expired session.
|
|
1159
|
+
let shouldFetchSchema = !schema || options.schema;
|
|
1160
|
+
if (!shouldFetchSchema && schema) {
|
|
1161
|
+
try {
|
|
1162
|
+
shouldFetchSchema = await isSchemaStale({ domain: effectiveDomain, verbose: options.verbose });
|
|
1163
|
+
if (shouldFetchSchema) log.dim(` Server schema is newer — refreshing _system dependency baseline`);
|
|
1164
|
+
} catch {
|
|
1165
|
+
// Can't check — continue with local schema
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (shouldFetchSchema) {
|
|
1169
|
+
try {
|
|
1170
|
+
schema = await fetchSchema({ domain: effectiveDomain, verbose: options.verbose });
|
|
1171
|
+
await saveSchema(schema);
|
|
1172
|
+
log.dim(` Refreshed _system dependency baseline`);
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
if (!schema) log.warn(` Could not fetch schema: ${err.message}`);
|
|
1175
|
+
// Continue with stale schema or null
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Regenerate metadata_schema.json for any new entity types
|
|
1180
|
+
if (schema) {
|
|
1181
|
+
const existing = await loadMetadataSchema();
|
|
1182
|
+
const updated = generateMetadataFromSchema(schema, existing ?? {});
|
|
1183
|
+
await saveMetadataSchema(updated);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1105
1186
|
// Domain change detection
|
|
1106
1187
|
if (effectiveDomain) {
|
|
1107
1188
|
const { changed, proceed } = await checkDomainChange(effectiveDomain, options);
|
|
@@ -1118,10 +1199,12 @@ export async function performClone(source, options = {}) {
|
|
|
1118
1199
|
await checkPendingSynchronize(options);
|
|
1119
1200
|
}
|
|
1120
1201
|
|
|
1121
|
-
// Ensure sensitive files are gitignored
|
|
1122
|
-
|
|
1202
|
+
// Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
|
|
1203
|
+
if (!isDependencyCheckout()) {
|
|
1204
|
+
await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json', '.app/scripts.local.json', '.app/errors.log', 'app_dependencies/']);
|
|
1205
|
+
}
|
|
1123
1206
|
|
|
1124
|
-
// Step 2: Update .
|
|
1207
|
+
// Step 2: Update .app/config.json (skip in pull mode — config already set)
|
|
1125
1208
|
if (!options.pullMode) {
|
|
1126
1209
|
await updateConfigWithApp({
|
|
1127
1210
|
AppID: appJson.AppID,
|
|
@@ -1130,9 +1213,9 @@ export async function performClone(source, options = {}) {
|
|
|
1130
1213
|
AppShortName: appJson.ShortName,
|
|
1131
1214
|
});
|
|
1132
1215
|
await saveCloneSource(activeSource || 'default');
|
|
1133
|
-
log.dim(' Updated .
|
|
1216
|
+
log.dim(' Updated .app/config.json with app metadata');
|
|
1134
1217
|
|
|
1135
|
-
// Merge Dependencies
|
|
1218
|
+
// Merge Dependencies into .app/config.json
|
|
1136
1219
|
// Always ensure at least ["_system"] is persisted
|
|
1137
1220
|
const fromApp = parseDependenciesColumn(appJson.Dependencies);
|
|
1138
1221
|
if (fromApp.length > 0) {
|
|
@@ -1164,8 +1247,8 @@ export async function performClone(source, options = {}) {
|
|
|
1164
1247
|
}
|
|
1165
1248
|
}
|
|
1166
1249
|
|
|
1167
|
-
// Step 3: Update package.json (skip in pull mode)
|
|
1168
|
-
if (!options.pullMode) {
|
|
1250
|
+
// Step 3: Update package.json (skip in pull mode and dependency checkouts)
|
|
1251
|
+
if (!options.pullMode && !isDependencyCheckout()) {
|
|
1169
1252
|
await updatePackageJson(appJson, config);
|
|
1170
1253
|
}
|
|
1171
1254
|
|
|
@@ -1183,7 +1266,7 @@ export async function performClone(source, options = {}) {
|
|
|
1183
1266
|
force: explicitDeps ? true : options.force,
|
|
1184
1267
|
schema: options.schema,
|
|
1185
1268
|
verbose: options.verbose,
|
|
1186
|
-
systemSchemaPath: join(process.cwd(),
|
|
1269
|
+
systemSchemaPath: join(process.cwd(), 'app_dependencies', '_system', '.app', '_system.json'),
|
|
1187
1270
|
only: explicitDeps || undefined,
|
|
1188
1271
|
});
|
|
1189
1272
|
} catch (err) {
|
|
@@ -1209,19 +1292,28 @@ export async function performClone(source, options = {}) {
|
|
|
1209
1292
|
const structure = buildBinHierarchy(bins, appJson.AppID);
|
|
1210
1293
|
|
|
1211
1294
|
if (!options.pullMode) {
|
|
1212
|
-
|
|
1295
|
+
// Inside app_dependencies/: skip development-only scaffold dirs (src, test, trash)
|
|
1296
|
+
const isDependencyCheckout = process.cwd().includes(`${sep}app_dependencies${sep}`);
|
|
1297
|
+
const DEP_SKIP_DIRS = new Set(['src', 'test', 'trash']);
|
|
1298
|
+
const dirsToScaffold = isDependencyCheckout
|
|
1299
|
+
? SCAFFOLD_DIRS.filter(d => !DEP_SKIP_DIRS.has(d))
|
|
1300
|
+
: SCAFFOLD_DIRS;
|
|
1301
|
+
|
|
1302
|
+
for (const dir of dirsToScaffold) {
|
|
1213
1303
|
await mkdir(dir, { recursive: true });
|
|
1214
1304
|
}
|
|
1215
1305
|
|
|
1216
1306
|
// Best-effort: apply trash icon
|
|
1217
|
-
|
|
1307
|
+
if (!isDependencyCheckout) {
|
|
1308
|
+
await applyTrashIcon(join(process.cwd(), 'trash'));
|
|
1309
|
+
}
|
|
1218
1310
|
|
|
1219
1311
|
const createdDirs = await createDirectories(structure);
|
|
1220
1312
|
await saveStructureFile(structure);
|
|
1221
1313
|
|
|
1222
|
-
const totalDirs =
|
|
1314
|
+
const totalDirs = dirsToScaffold.length + createdDirs.length;
|
|
1223
1315
|
log.success(`Created ${totalDirs} director${totalDirs === 1 ? 'y' : 'ies'}`);
|
|
1224
|
-
for (const d of
|
|
1316
|
+
for (const d of dirsToScaffold) log.dim(` ${d}/`);
|
|
1225
1317
|
for (const d of createdDirs) log.dim(` ${d}/`);
|
|
1226
1318
|
|
|
1227
1319
|
// Warn about legacy root-level entity directories
|
|
@@ -1250,7 +1342,7 @@ export async function performClone(source, options = {}) {
|
|
|
1250
1342
|
if (!serverTz || serverTz === 'UTC') {
|
|
1251
1343
|
serverTz = 'America/Los_Angeles';
|
|
1252
1344
|
await updateConfigWithApp({ ServerTimezone: serverTz });
|
|
1253
|
-
log.dim(` Set ServerTimezone to ${serverTz} in .
|
|
1345
|
+
log.dim(` Set ServerTimezone to ${serverTz} in .app/config.json`);
|
|
1254
1346
|
}
|
|
1255
1347
|
|
|
1256
1348
|
// Resolve --entity filter: which entity types to process
|
|
@@ -1300,8 +1392,8 @@ export async function performClone(source, options = {}) {
|
|
|
1300
1392
|
);
|
|
1301
1393
|
}
|
|
1302
1394
|
|
|
1303
|
-
// Step 5a: Write manifest.json to project root (
|
|
1304
|
-
if (!entityFilter || entityFilter.has('content')) {
|
|
1395
|
+
// Step 5a: Write manifest.json to project root (skip for dependency checkouts)
|
|
1396
|
+
if ((!entityFilter || entityFilter.has('content')) && !isDependencyCheckout()) {
|
|
1305
1397
|
await writeManifestJson(appJson, contentRefs);
|
|
1306
1398
|
}
|
|
1307
1399
|
|
|
@@ -1358,7 +1450,7 @@ export async function performClone(source, options = {}) {
|
|
|
1358
1450
|
// Step 7: Save app.json with references
|
|
1359
1451
|
await saveAppJson(appJson, contentRefs, otherRefs, effectiveDomain);
|
|
1360
1452
|
|
|
1361
|
-
// Step 8: Create .
|
|
1453
|
+
// Step 8: Create .app/<shortName>.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
|
|
1362
1454
|
if (!entityFilter) {
|
|
1363
1455
|
await saveBaselineFile(appJson);
|
|
1364
1456
|
resetBaselineCache(); // invalidate so next operation reloads the fresh baseline
|
|
@@ -1374,13 +1466,17 @@ export async function performClone(source, options = {}) {
|
|
|
1374
1466
|
// Step 9: Trash orphaned legacy ~UID companion files that no metadata references
|
|
1375
1467
|
await trashOrphanedLegacyCompanions();
|
|
1376
1468
|
|
|
1469
|
+
// Step 9b: Clean up double-metadata files (e.g., "app.metadata.metadata~uid.json")
|
|
1470
|
+
// caused by an older bug where buildMetaFilename received a base already containing ".metadata"
|
|
1471
|
+
await cleanDoubleMetadataFiles();
|
|
1472
|
+
|
|
1377
1473
|
// Step 10: Tag project files with sync status (best-effort, non-blocking)
|
|
1378
1474
|
tagProjectFiles({ verbose: false }).catch(() => {});
|
|
1379
1475
|
|
|
1380
1476
|
log.plain('');
|
|
1381
1477
|
const verb = options.pullMode ? 'Pull' : 'Clone';
|
|
1382
1478
|
log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
|
|
1383
|
-
log.dim('
|
|
1479
|
+
log.dim(' App metadata saved to .app/');
|
|
1384
1480
|
if (!options.pullMode) {
|
|
1385
1481
|
log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
|
|
1386
1482
|
}
|
|
@@ -1546,7 +1642,24 @@ async function fetchAppFromServer(appShortName, options, config) {
|
|
|
1546
1642
|
throw new Error(`No app found with ShortName "${appShortName}"`);
|
|
1547
1643
|
}
|
|
1548
1644
|
|
|
1549
|
-
|
|
1645
|
+
// Heuristic: detect sparse responses — may indicate expired session or
|
|
1646
|
+
// limited permissions on the target app. Warn but proceed: the server may
|
|
1647
|
+
// intentionally scope /api/app/object responses by user security.
|
|
1648
|
+
const children = appRecord.children || {};
|
|
1649
|
+
const childKeys = Object.keys(children);
|
|
1650
|
+
const hasContentOrMedia = children.content?.length > 0 || children.media?.length > 0 || children.bin?.length > 0;
|
|
1651
|
+
if (childKeys.length > 0 && !hasContentOrMedia) {
|
|
1652
|
+
const totalRecords = childKeys.reduce((sum, k) => sum + (Array.isArray(children[k]) ? children[k].length : 0), 0);
|
|
1653
|
+
if (totalRecords <= 5) {
|
|
1654
|
+
spinner.warn(`App "${appShortName}" returned sparse data (${totalRecords} record(s), no content/media/bins)`);
|
|
1655
|
+
log.warn(' If data is missing, check permissions or run "dbo login" and re-clone.');
|
|
1656
|
+
} else {
|
|
1657
|
+
spinner.succeed(`Found app on server`);
|
|
1658
|
+
}
|
|
1659
|
+
} else {
|
|
1660
|
+
spinner.succeed(`Found app on server`);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1550
1663
|
return appRecord;
|
|
1551
1664
|
}
|
|
1552
1665
|
|
|
@@ -1967,7 +2080,14 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1967
2080
|
const extractedContentCols = [];
|
|
1968
2081
|
|
|
1969
2082
|
for (const [key, value] of Object.entries(record)) {
|
|
1970
|
-
if (key === 'children')
|
|
2083
|
+
if (key === 'children') {
|
|
2084
|
+
// Embed children inline with base64 decoding (entity_column, security_column, etc.)
|
|
2085
|
+
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
2086
|
+
&& Object.keys(value).length > 0) {
|
|
2087
|
+
meta.children = embedEntityChildren(value);
|
|
2088
|
+
}
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
1971
2091
|
|
|
1972
2092
|
// Check if this column should be extracted as a companion file
|
|
1973
2093
|
const extractInfo = contentColsToExtract.find(c => c.col === key);
|
|
@@ -2105,7 +2225,7 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
|
|
|
2105
2225
|
}
|
|
2106
2226
|
|
|
2107
2227
|
await saveDescriptorMapping(structure, mapping);
|
|
2108
|
-
log.dim(` Saved descriptorMapping to .
|
|
2228
|
+
log.dim(` Saved descriptorMapping to .app/directories.json`);
|
|
2109
2229
|
|
|
2110
2230
|
// Parse form-control-code from descriptor_definition records → populate metadata_schema.json
|
|
2111
2231
|
const descriptorDefs = extensionEntries.filter(r =>
|
|
@@ -2545,7 +2665,14 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2545
2665
|
const extractedCols = [];
|
|
2546
2666
|
|
|
2547
2667
|
for (const [key, value] of Object.entries(record)) {
|
|
2548
|
-
if (key === 'children')
|
|
2668
|
+
if (key === 'children') {
|
|
2669
|
+
// Embed children inline with base64 decoding
|
|
2670
|
+
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
2671
|
+
&& Object.keys(value).length > 0) {
|
|
2672
|
+
meta.children = embedEntityChildren(value);
|
|
2673
|
+
}
|
|
2674
|
+
continue;
|
|
2675
|
+
}
|
|
2549
2676
|
|
|
2550
2677
|
const companionRef = companionRefs.find(r => r.column.toLowerCase() === key.toLowerCase());
|
|
2551
2678
|
if (companionRef) {
|
|
@@ -2878,7 +3005,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2878
3005
|
log.dim(` Error: ${err.message}`);
|
|
2879
3006
|
}
|
|
2880
3007
|
|
|
2881
|
-
// Append to .
|
|
3008
|
+
// Append to .app/errors.log
|
|
2882
3009
|
await appendErrorLog({
|
|
2883
3010
|
timestamp: new Date().toISOString(),
|
|
2884
3011
|
command: 'clone',
|
|
@@ -2957,7 +3084,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2957
3084
|
});
|
|
2958
3085
|
log.dim(` Staged: ${stale.filename}`);
|
|
2959
3086
|
}
|
|
2960
|
-
log.success('Stale media records staged in .
|
|
3087
|
+
log.success('Stale media records staged in .app/synchronize.json');
|
|
2961
3088
|
log.dim(' Run "dbo push" to delete from server');
|
|
2962
3089
|
}
|
|
2963
3090
|
|
|
@@ -3024,7 +3151,9 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3024
3151
|
}
|
|
3025
3152
|
|
|
3026
3153
|
// If no extension determined and Content column has data, prompt user to choose one
|
|
3027
|
-
|
|
3154
|
+
// Only prompt when --configure is set; otherwise skip silently (companion extraction
|
|
3155
|
+
// should not surprise the user with interactive prompts during normal clone/pull)
|
|
3156
|
+
if (!ext && options.configure && !options.yes && record.Content) {
|
|
3028
3157
|
const cv = record.Content;
|
|
3029
3158
|
const hasContentData = cv && (
|
|
3030
3159
|
(typeof cv === 'object' && cv.value !== null && cv.value !== undefined) ||
|
|
@@ -3574,27 +3703,23 @@ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, ser
|
|
|
3574
3703
|
|
|
3575
3704
|
/**
|
|
3576
3705
|
* Recursively build a children object for a parent entity.
|
|
3577
|
-
* Mutates parentObj to set parentObj.children
|
|
3578
|
-
*
|
|
3706
|
+
* Mutates parentObj to set parentObj.children keyed by physical entity names:
|
|
3707
|
+
* { output_value: [], output_value_filter: [], output_value_entity_column_rel: [] }
|
|
3579
3708
|
*
|
|
3580
3709
|
* Each child object retains _entity set to the physical entity name
|
|
3581
|
-
* (output_value, output_value_entity_column_rel, output_value_filter)
|
|
3582
3710
|
* so that push can route submissions correctly.
|
|
3583
3711
|
*
|
|
3712
|
+
* Child CustomSQL values are decoded inline as strings — only the root output's
|
|
3713
|
+
* CustomSQL is extracted as a companion .sql file (done by the caller).
|
|
3714
|
+
*
|
|
3584
3715
|
* @param {Object} parentObj - The entity object to populate (mutated in place)
|
|
3585
3716
|
* @param {Object} node - Tree node from buildOutputHierarchyTree (has _children)
|
|
3586
|
-
* @param {string} rootStem - Root output file stem (e.g. "Sales~abc")
|
|
3587
|
-
* @param {string} outputDir - Directory where root output JSON lives
|
|
3588
|
-
* @param {string} serverTz - Server timezone
|
|
3589
|
-
* @param {string} [parentStem] - Ancestor stem for compound companion naming
|
|
3590
|
-
* @returns {Promise<string[]>} - Array of written companion file basenames
|
|
3591
3717
|
*/
|
|
3592
|
-
|
|
3593
|
-
const companionFiles = [];
|
|
3718
|
+
export function buildInlineOutputChildren(parentObj, node) {
|
|
3594
3719
|
const nodeChildren = node._children || {};
|
|
3595
3720
|
|
|
3596
|
-
//
|
|
3597
|
-
parentObj.children = {
|
|
3721
|
+
// Use physical entity names as children keys (not doc aliases)
|
|
3722
|
+
parentObj.children = { output_value: [], output_value_filter: [], output_value_entity_column_rel: [] };
|
|
3598
3723
|
|
|
3599
3724
|
for (const docKey of INLINE_DOC_KEYS) {
|
|
3600
3725
|
const entityArray = nodeChildren[docKey];
|
|
@@ -3602,44 +3727,32 @@ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, s
|
|
|
3602
3727
|
|
|
3603
3728
|
if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
|
|
3604
3729
|
|
|
3605
|
-
for (
|
|
3606
|
-
const child = entityArray[childIdx];
|
|
3730
|
+
for (const child of entityArray) {
|
|
3607
3731
|
// Build a clean copy without tree-internal fields
|
|
3608
3732
|
const childObj = { ...child };
|
|
3609
3733
|
delete childObj._children;
|
|
3610
3734
|
|
|
3611
|
-
// Decode
|
|
3735
|
+
// Decode all base64 fields inline (including CustomSQL — only root output extracts SQL files)
|
|
3612
3736
|
for (const [key, value] of Object.entries(childObj)) {
|
|
3613
|
-
if (key === 'CustomSQL') continue; // handled by extractCustomSqlIfNeeded
|
|
3614
3737
|
if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
|
|
3615
|
-
childObj[key] = resolveContentValue(value);
|
|
3738
|
+
childObj[key] = resolveContentValue(value) ?? '';
|
|
3616
3739
|
}
|
|
3617
3740
|
}
|
|
3618
3741
|
|
|
3619
3742
|
// Ensure _entity is set to physical entity name (for push routing)
|
|
3620
3743
|
childObj._entity = physicalKey;
|
|
3621
3744
|
|
|
3622
|
-
// Compute companion stem for this child (index-based, not UID-based)
|
|
3623
|
-
const childStem = getChildCompanionStem(rootStem, physicalKey, childIdx, parentStem);
|
|
3624
|
-
|
|
3625
|
-
// Extract CustomSQL if needed
|
|
3626
|
-
const companionFile = await extractCustomSqlIfNeeded(childObj, childStem, outputDir, serverTz);
|
|
3627
|
-
if (companionFile) companionFiles.push(companionFile);
|
|
3628
|
-
|
|
3629
3745
|
// Recurse into child's _children (e.g. join→column, column→filter)
|
|
3630
3746
|
if (child._children && Object.keys(child._children).some(k => child._children[k]?.length > 0)) {
|
|
3631
|
-
|
|
3632
|
-
companionFiles.push(...gcFiles);
|
|
3747
|
+
buildInlineOutputChildren(childObj, child);
|
|
3633
3748
|
} else {
|
|
3634
3749
|
// Leaf node: still set empty children
|
|
3635
|
-
childObj.children = {
|
|
3750
|
+
childObj.children = { output_value: [], output_value_filter: [], output_value_entity_column_rel: [] };
|
|
3636
3751
|
}
|
|
3637
3752
|
|
|
3638
|
-
parentObj.children[
|
|
3753
|
+
parentObj.children[physicalKey].push(childObj);
|
|
3639
3754
|
}
|
|
3640
3755
|
}
|
|
3641
|
-
|
|
3642
|
-
return companionFiles;
|
|
3643
3756
|
}
|
|
3644
3757
|
|
|
3645
3758
|
/**
|
|
@@ -3661,8 +3774,8 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
|
3661
3774
|
for (const f of files) {
|
|
3662
3775
|
const matchesCurrent = f.startsWith(`${rootStem}.`);
|
|
3663
3776
|
const matchesLegacy = f.startsWith(`${legacyStem}.`);
|
|
3664
|
-
if ((matchesCurrent || matchesLegacy) && /\.(column|join|filter)
|
|
3665
|
-
// Old child file or
|
|
3777
|
+
if ((matchesCurrent || matchesLegacy) && /\.(column|join|filter)(~|-\d+\.|\.CustomSQL)/.test(f)) {
|
|
3778
|
+
// Old child file, legacy child JSON, or old per-child CustomSQL companion — trash it
|
|
3666
3779
|
if (!trashCreated) {
|
|
3667
3780
|
await mkdir(trashDir, { recursive: true });
|
|
3668
3781
|
trashCreated = true;
|
|
@@ -3814,6 +3927,26 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3814
3927
|
// Resolve filename columns for each entity type
|
|
3815
3928
|
const filenameCols = await resolveOutputFilenameColumns(appJson, options);
|
|
3816
3929
|
|
|
3930
|
+
// Detect companion filename collisions: multiple outputs that produce the same
|
|
3931
|
+
// rootBasename + binDir. When collisions exist, use UID-qualified companion stems
|
|
3932
|
+
// so each output's .CustomSQL.sql doesn't overwrite the others.
|
|
3933
|
+
const companionKey = (output) => {
|
|
3934
|
+
let binDir = BINS_DIR;
|
|
3935
|
+
if (output.BinID && structure[output.BinID]) {
|
|
3936
|
+
binDir = resolveBinPath(output.BinID, structure);
|
|
3937
|
+
}
|
|
3938
|
+
const base = buildOutputFilename('output', output, filenameCols.output);
|
|
3939
|
+
return `${binDir}/${base}`;
|
|
3940
|
+
};
|
|
3941
|
+
const companionKeyCounts = new Map();
|
|
3942
|
+
for (const output of tree) {
|
|
3943
|
+
const key = companionKey(output);
|
|
3944
|
+
companionKeyCounts.set(key, (companionKeyCounts.get(key) || 0) + 1);
|
|
3945
|
+
}
|
|
3946
|
+
const collidingCompanionKeys = new Set(
|
|
3947
|
+
[...companionKeyCounts.entries()].filter(([, count]) => count > 1).map(([key]) => key)
|
|
3948
|
+
);
|
|
3949
|
+
|
|
3817
3950
|
const refs = [];
|
|
3818
3951
|
const bulkAction = { value: null };
|
|
3819
3952
|
const config = await loadConfig();
|
|
@@ -3924,11 +4057,16 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3924
4057
|
// Copy raw CustomSQL for extraction helper
|
|
3925
4058
|
rootMeta.CustomSQL = output.CustomSQL;
|
|
3926
4059
|
|
|
4060
|
+
// When multiple outputs share the same name+bin, qualify the companion stem
|
|
4061
|
+
// with the UID so each gets its own .CustomSQL.sql file.
|
|
4062
|
+
const isCollision = collidingCompanionKeys.has(`${binDir}/${rootBasename}`);
|
|
4063
|
+
const companionStem = isCollision ? `${rootBasename}~${rootUid}` : rootBasename;
|
|
4064
|
+
|
|
3927
4065
|
// Extract CustomSQL on root (rules 1/2/3)
|
|
3928
|
-
await extractCustomSqlIfNeeded(rootMeta,
|
|
4066
|
+
await extractCustomSqlIfNeeded(rootMeta, companionStem, binDir, serverTz);
|
|
3929
4067
|
|
|
3930
4068
|
// Embed all children under rootMeta.children = { column, join, filter }
|
|
3931
|
-
|
|
4069
|
+
buildInlineOutputChildren(rootMeta, output);
|
|
3932
4070
|
// rootMeta now has .children = { column: [...], join: [...], filter: [...] }
|
|
3933
4071
|
|
|
3934
4072
|
await writeFile(rootMetaPath, JSON.stringify(rootMeta, null, 2) + '\n');
|
|
@@ -4030,11 +4168,11 @@ async function writeManifestJson(appJson, contentRefs) {
|
|
|
4030
4168
|
};
|
|
4031
4169
|
|
|
4032
4170
|
await writeFile('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
4033
|
-
log.dim(' manifest.json generated at project root (from app
|
|
4171
|
+
log.dim(' manifest.json generated at project root (from app metadata)');
|
|
4034
4172
|
}
|
|
4035
4173
|
|
|
4036
4174
|
/**
|
|
4037
|
-
* Save app
|
|
4175
|
+
* Save app metadata to project root with @ references replacing processed entries.
|
|
4038
4176
|
*/
|
|
4039
4177
|
async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
|
|
4040
4178
|
const output = { ...appJson };
|
|
@@ -4072,11 +4210,12 @@ async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
|
|
|
4072
4210
|
delete output.children.output_value_entity_column_rel;
|
|
4073
4211
|
}
|
|
4074
4212
|
|
|
4075
|
-
|
|
4213
|
+
const metaPath = await appMetadataPath();
|
|
4214
|
+
await writeFile(metaPath, JSON.stringify(output, null, 2) + '\n');
|
|
4076
4215
|
}
|
|
4077
4216
|
|
|
4078
4217
|
/**
|
|
4079
|
-
* Save .
|
|
4218
|
+
* Save .app/<shortName>.json baseline file with decoded base64 values.
|
|
4080
4219
|
* This file tracks the server state for delta detection.
|
|
4081
4220
|
*/
|
|
4082
4221
|
async function saveBaselineFile(appJson) {
|
|
@@ -4086,10 +4225,10 @@ async function saveBaselineFile(appJson) {
|
|
|
4086
4225
|
// Recursively decode all base64 fields
|
|
4087
4226
|
decodeBase64Fields(baseline);
|
|
4088
4227
|
|
|
4089
|
-
// Save
|
|
4228
|
+
// Save baseline
|
|
4090
4229
|
await saveAppJsonBaseline(baseline);
|
|
4091
4230
|
|
|
4092
|
-
log.dim(' .
|
|
4231
|
+
log.dim(' .app/ baseline created (system-managed, do not edit)');
|
|
4093
4232
|
}
|
|
4094
4233
|
|
|
4095
4234
|
/**
|
|
@@ -4125,10 +4264,10 @@ export function decodeBase64Fields(obj) {
|
|
|
4125
4264
|
|
|
4126
4265
|
// ── Error log ─────────────────────────────────────────────────────────────
|
|
4127
4266
|
|
|
4128
|
-
const ERROR_LOG_PATH = join('.
|
|
4267
|
+
const ERROR_LOG_PATH = join('.app', 'errors.log');
|
|
4129
4268
|
|
|
4130
4269
|
/**
|
|
4131
|
-
* Append a structured error entry to .
|
|
4270
|
+
* Append a structured error entry to .app/errors.log.
|
|
4132
4271
|
* Creates the file if absent. Each entry is one JSON line (JSONL format).
|
|
4133
4272
|
*/
|
|
4134
4273
|
async function appendErrorLog(entry) {
|
package/src/commands/deploy.js
CHANGED
|
@@ -9,12 +9,12 @@ import { resolveTransactionKey } from '../lib/transaction-key.js';
|
|
|
9
9
|
import { log } from '../lib/logger.js';
|
|
10
10
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
11
11
|
|
|
12
|
-
const MANIFEST_FILE = '.
|
|
12
|
+
const MANIFEST_FILE = '.app/deploy_config.json';
|
|
13
13
|
const LEGACY_MANIFEST_FILE = 'dbo.deploy.json';
|
|
14
14
|
|
|
15
15
|
export const deployCommand = new Command('deploy')
|
|
16
16
|
.description('Deploy files to DBO.io using a manifest or direct arguments')
|
|
17
|
-
.argument('[name]', 'Deployment name from .
|
|
17
|
+
.argument('[name]', 'Deployment name from .app/deploy_config.json (e.g., css:colors)')
|
|
18
18
|
.option('--all', 'Deploy all entries in the manifest')
|
|
19
19
|
.option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
|
|
20
20
|
.option('--ticket <id>', 'Override ticket ID')
|
|
@@ -29,7 +29,7 @@ export const deployCommand = new Command('deploy')
|
|
|
29
29
|
await runPendingMigrations(options);
|
|
30
30
|
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
31
31
|
|
|
32
|
-
// Load manifest — try .
|
|
32
|
+
// Load manifest — try .app/deploy_config.json first, fall back to legacy dbo.deploy.json
|
|
33
33
|
let manifest;
|
|
34
34
|
let manifestSource;
|
|
35
35
|
try {
|