@dboio/cli 0.15.3 → 0.16.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 +103 -25
- package/package.json +1 -1
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +103 -25
- package/src/commands/add.js +18 -18
- package/src/commands/clone.js +361 -139
- package/src/commands/init.js +42 -1
- package/src/commands/input.js +2 -32
- package/src/commands/mv.js +3 -3
- package/src/commands/push.js +12 -8
- package/src/commands/rm.js +2 -2
- package/src/lib/columns.js +1 -0
- package/src/lib/config.js +83 -1
- package/src/lib/delta.js +3 -2
- package/src/lib/dependencies.js +217 -2
- package/src/lib/diff.js +9 -11
- package/src/lib/filenames.js +2 -2
- package/src/lib/ignore.js +1 -0
- package/src/lib/logger.js +35 -0
- package/src/lib/metadata-schema.js +492 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/schema.js +53 -0
- package/src/lib/structure.js +3 -3
- package/src/lib/tagging.js +1 -1
- package/src/lib/toe-stepping.js +2 -2
- package/src/migrations/007-natural-entity-companion-filenames.js +5 -2
- package/src/migrations/011-schema-driven-metadata.js +120 -0
package/src/commands/init.js
CHANGED
|
@@ -9,6 +9,10 @@ import { log } from '../lib/logger.js';
|
|
|
9
9
|
import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
|
|
10
10
|
import { performLogin } from './login.js';
|
|
11
11
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
12
|
+
import { fetchSchema, saveSchema, SCHEMA_FILE } from '../lib/schema.js';
|
|
13
|
+
import { loadMetadataSchema, saveMetadataSchema, generateMetadataFromSchema } from '../lib/metadata-schema.js';
|
|
14
|
+
import { syncDependencies } from '../lib/dependencies.js';
|
|
15
|
+
import { mergeDependencies } from '../lib/config.js';
|
|
12
16
|
|
|
13
17
|
export const initCommand = new Command('init')
|
|
14
18
|
.description('Initialize DBO CLI configuration for the current directory')
|
|
@@ -25,6 +29,8 @@ export const initCommand = new Command('init')
|
|
|
25
29
|
.option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
|
|
26
30
|
.option('--media-placement <placement>', 'Set media placement when cloning: fullpath or binpath (default: bin)')
|
|
27
31
|
.option('--no-migrate', 'Skip pending migrations for this invocation')
|
|
32
|
+
.option('--no-deps', 'Skip dependency cloning after init')
|
|
33
|
+
.option('--dependencies <apps>', 'Sync specific dependency apps (comma-separated short-names)')
|
|
28
34
|
.action(async (options) => {
|
|
29
35
|
// Merge --yes into nonInteractive
|
|
30
36
|
if (options.yes) options.nonInteractive = true;
|
|
@@ -102,7 +108,7 @@ export const initCommand = new Command('init')
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
// Ensure sensitive files are gitignored
|
|
105
|
-
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/scripts.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r']);
|
|
111
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/scripts.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r', 'schema.json', '.dbo/dependencies/']);
|
|
106
112
|
|
|
107
113
|
const createdIgnore = await createDboignore();
|
|
108
114
|
if (createdIgnore) log.dim(' Created .dboignore');
|
|
@@ -135,6 +141,41 @@ export const initCommand = new Command('init')
|
|
|
135
141
|
await performLogin(domain, username);
|
|
136
142
|
}
|
|
137
143
|
|
|
144
|
+
// Attempt schema fetch (best-effort — silently skip if not authenticated yet)
|
|
145
|
+
try {
|
|
146
|
+
const schemaData = await fetchSchema({ domain, verbose: options.verbose });
|
|
147
|
+
await saveSchema(schemaData);
|
|
148
|
+
log.dim(` Saved ${SCHEMA_FILE}`);
|
|
149
|
+
|
|
150
|
+
const existing = await loadMetadataSchema();
|
|
151
|
+
const updated = generateMetadataFromSchema(schemaData, existing ?? {});
|
|
152
|
+
await saveMetadataSchema(updated);
|
|
153
|
+
log.dim(` Updated .dbo/metadata_schema.json`);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
log.warn(` Could not fetch schema (${err.message}) — run 'dbo clone --schema' after login.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sync dependency apps (e.g., _system) — best-effort, non-blocking
|
|
159
|
+
if (!options.noDeps) {
|
|
160
|
+
const explicitDeps = options.dependencies
|
|
161
|
+
? options.dependencies.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
|
162
|
+
: null;
|
|
163
|
+
if (explicitDeps && explicitDeps.length > 0) {
|
|
164
|
+
await mergeDependencies(explicitDeps);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await syncDependencies({
|
|
168
|
+
domain,
|
|
169
|
+
force: explicitDeps ? true : undefined,
|
|
170
|
+
verbose: options.verbose,
|
|
171
|
+
systemSchemaPath: join(process.cwd(), SCHEMA_FILE),
|
|
172
|
+
only: explicitDeps || undefined,
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log.warn(` Dependency sync failed: ${err.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
138
179
|
// TransactionKeyPreset — always RowUID (stable across domains)
|
|
139
180
|
await saveTransactionKeyPreset('RowUID');
|
|
140
181
|
log.dim(' TransactionKeyPreset: RowUID');
|
package/src/commands/input.js
CHANGED
|
@@ -2,7 +2,6 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { DboClient } from '../lib/client.js';
|
|
3
3
|
import { buildInputBody, parseFileArg, checkSubmitErrors } from '../lib/input-parser.js';
|
|
4
4
|
import { formatResponse, formatError } from '../lib/formatter.js';
|
|
5
|
-
import { loadAppConfig } from '../lib/config.js';
|
|
6
5
|
import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
|
|
7
6
|
import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
|
|
8
7
|
import { log } from '../lib/logger.js';
|
|
@@ -59,37 +58,8 @@ export const inputCommand = new Command('input')
|
|
|
59
58
|
}
|
|
60
59
|
if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
|
|
61
60
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
const allDataText = options.data.join(' ');
|
|
65
|
-
const isDeleteOnly = options.data.every(d => /RowID:del\d+/.test(d));
|
|
66
|
-
const hasAppId = isDeleteOnly || /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
|
|
67
|
-
if (!hasAppId) {
|
|
68
|
-
const appConfig = await loadAppConfig();
|
|
69
|
-
if (appConfig.AppID) {
|
|
70
|
-
const inquirer = (await import('inquirer')).default;
|
|
71
|
-
const { appIdChoice } = await inquirer.prompt([{
|
|
72
|
-
type: 'list',
|
|
73
|
-
name: 'appIdChoice',
|
|
74
|
-
message: `You're submitting data without an AppID, but your config has information about the current App. Do you want me to add that Column information along with your submission?`,
|
|
75
|
-
choices: [
|
|
76
|
-
{ name: `Yes, use AppID ${appConfig.AppID}`, value: 'use_config' },
|
|
77
|
-
{ name: 'No', value: 'none' },
|
|
78
|
-
{ name: 'Enter custom AppID', value: 'custom' },
|
|
79
|
-
],
|
|
80
|
-
}]);
|
|
81
|
-
if (appIdChoice === 'use_config') {
|
|
82
|
-
extraParams['AppID'] = String(appConfig.AppID);
|
|
83
|
-
log.dim(` Using AppID ${appConfig.AppID} from config`);
|
|
84
|
-
} else if (appIdChoice === 'custom') {
|
|
85
|
-
const { customAppId } = await inquirer.prompt([{
|
|
86
|
-
type: 'input', name: 'customAppId',
|
|
87
|
-
message: 'Custom AppID:',
|
|
88
|
-
}]);
|
|
89
|
-
if (customAppId.trim()) extraParams['AppID'] = customAppId.trim();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
61
|
+
// dbo input is a low-level command — don't prompt for AppID.
|
|
62
|
+
// AppID prompting belongs in push/deploy where it's contextually required.
|
|
93
63
|
|
|
94
64
|
if (options.file.length > 0) {
|
|
95
65
|
// Multipart mode
|
package/src/commands/mv.js
CHANGED
|
@@ -607,8 +607,8 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
|
|
|
607
607
|
if (newRelativePath) metaUpdates.Path = newRelativePath;
|
|
608
608
|
|
|
609
609
|
// Update content column references if file was renamed
|
|
610
|
-
if (conflict.action === 'rename' && finalContentName && meta._contentColumns) {
|
|
611
|
-
for (const col of meta._contentColumns) {
|
|
610
|
+
if (conflict.action === 'rename' && finalContentName && (meta._companionReferenceColumns || meta._contentColumns)) {
|
|
611
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns)) {
|
|
612
612
|
if (meta[col] && String(meta[col]).startsWith('@')) {
|
|
613
613
|
metaUpdates[col] = `@${finalContentName}`;
|
|
614
614
|
}
|
|
@@ -677,7 +677,7 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
|
|
|
677
677
|
// Update deploy config: remove old entry (by UID), re-insert with new path + correct key
|
|
678
678
|
await removeDeployEntry(uid);
|
|
679
679
|
if (newContentPath) {
|
|
680
|
-
const col = (meta._contentColumns || [])[0] || 'Content';
|
|
680
|
+
const col = (meta._companionReferenceColumns || meta._contentColumns || [])[0] || 'Content';
|
|
681
681
|
await upsertDeployEntry(newContentPath, uid, entity, col);
|
|
682
682
|
} else if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
683
683
|
const movedMediaPath = join(targetDir, String(meta._mediaFile).substring(1));
|
package/src/commands/push.js
CHANGED
|
@@ -269,7 +269,7 @@ async function moveWillDeleteToTrash(entry) {
|
|
|
269
269
|
// Read the __WILL_DELETE__ metadata to find associated content files
|
|
270
270
|
const rawMeta = await readFile(willDeleteMeta, 'utf8');
|
|
271
271
|
const deletedMeta = JSON.parse(rawMeta);
|
|
272
|
-
for (const col of (deletedMeta._contentColumns || [])) {
|
|
272
|
+
for (const col of (deletedMeta._companionReferenceColumns || deletedMeta._contentColumns || [])) {
|
|
273
273
|
const ref = deletedMeta[col];
|
|
274
274
|
if (ref && String(ref).startsWith('@')) {
|
|
275
275
|
const refFile = String(ref).substring(1);
|
|
@@ -439,7 +439,7 @@ async function ensureManifestMetadata() {
|
|
|
439
439
|
|
|
440
440
|
const meta = {
|
|
441
441
|
_entity: 'content',
|
|
442
|
-
|
|
442
|
+
_companionReferenceColumns: ['Content'],
|
|
443
443
|
Content: '@/manifest.json',
|
|
444
444
|
Path: 'manifest.json',
|
|
445
445
|
Name: 'manifest.json',
|
|
@@ -535,6 +535,10 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
535
535
|
log.warn('No .app.json baseline found — performing full push (run "dbo clone" to enable delta sync)');
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
// Load server timezone for delta date comparisons
|
|
539
|
+
const pushConfig = await loadConfig();
|
|
540
|
+
const serverTz = pushConfig.ServerTimezone || 'America/Los_Angeles';
|
|
541
|
+
|
|
538
542
|
// Collect metadata with detected changes
|
|
539
543
|
const toPush = [];
|
|
540
544
|
const outputCompoundFiles = [];
|
|
@@ -583,7 +587,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
583
587
|
const isNewRecord = !meta.UID && !meta._id;
|
|
584
588
|
|
|
585
589
|
// Verify @file references exist
|
|
586
|
-
const contentCols = meta._contentColumns || [];
|
|
590
|
+
const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
|
|
587
591
|
let missingFiles = false;
|
|
588
592
|
for (const col of contentCols) {
|
|
589
593
|
const ref = meta[col];
|
|
@@ -624,7 +628,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
624
628
|
let changedColumns = null;
|
|
625
629
|
if (!isNewRecord && baseline) {
|
|
626
630
|
try {
|
|
627
|
-
changedColumns = await detectChangedColumns(metaPath, baseline);
|
|
631
|
+
changedColumns = await detectChangedColumns(metaPath, baseline, serverTz);
|
|
628
632
|
if (changedColumns.length === 0) {
|
|
629
633
|
log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
|
|
630
634
|
skipped++;
|
|
@@ -1009,7 +1013,7 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
|
|
|
1009
1013
|
*/
|
|
1010
1014
|
async function addFromMetadata(meta, metaPath, client, options, modifyKey = null) {
|
|
1011
1015
|
const entity = meta._entity;
|
|
1012
|
-
const contentCols = new Set(meta._contentColumns || []);
|
|
1016
|
+
const contentCols = new Set(meta._companionReferenceColumns || meta._contentColumns || []);
|
|
1013
1017
|
const metaDir = dirname(metaPath);
|
|
1014
1018
|
|
|
1015
1019
|
const dataExprs = [];
|
|
@@ -1135,7 +1139,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
1135
1139
|
const uid = meta.UID;
|
|
1136
1140
|
const id = meta._id;
|
|
1137
1141
|
const entity = meta._entity;
|
|
1138
|
-
const contentCols = new Set(meta._contentColumns || []);
|
|
1142
|
+
const contentCols = new Set(meta._companionReferenceColumns || meta._contentColumns || []);
|
|
1139
1143
|
const metaDir = dirname(metaPath);
|
|
1140
1144
|
|
|
1141
1145
|
// Determine the row key. TransactionKeyPreset only applies when the record
|
|
@@ -1361,7 +1365,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
1361
1365
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
1362
1366
|
await setFileTimestamps(metaPath, meta._CreatedOn, updated, serverTz);
|
|
1363
1367
|
// Update content file mtime too
|
|
1364
|
-
const contentCols = meta._contentColumns || [];
|
|
1368
|
+
const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
|
|
1365
1369
|
for (const col of contentCols) {
|
|
1366
1370
|
const ref = meta[col];
|
|
1367
1371
|
if (ref && String(ref).startsWith('@')) {
|
|
@@ -1451,7 +1455,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
|
|
|
1451
1455
|
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
1452
1456
|
|
|
1453
1457
|
// Find the content file referenced by @filename
|
|
1454
|
-
const contentCols = meta._contentColumns || [];
|
|
1458
|
+
const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
|
|
1455
1459
|
let contentFileName = null;
|
|
1456
1460
|
for (const col of contentCols) {
|
|
1457
1461
|
const ref = meta[col];
|
package/src/commands/rm.js
CHANGED
|
@@ -91,7 +91,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
|
|
|
91
91
|
const metaDir = dirname(metaPath);
|
|
92
92
|
const localFiles = [metaPath];
|
|
93
93
|
|
|
94
|
-
for (const col of (meta._contentColumns || [])) {
|
|
94
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
95
95
|
const ref = meta[col];
|
|
96
96
|
if (ref && String(ref).startsWith('@')) {
|
|
97
97
|
localFiles.push(join(metaDir, String(ref).substring(1)));
|
|
@@ -197,7 +197,7 @@ async function rmFile(filePath, options) {
|
|
|
197
197
|
// Collect local files for display
|
|
198
198
|
const metaDir = dirname(metaPath);
|
|
199
199
|
const localFiles = [metaPath];
|
|
200
|
-
for (const col of (meta._contentColumns || [])) {
|
|
200
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
201
201
|
const ref = meta[col];
|
|
202
202
|
if (ref && String(ref).startsWith('@')) {
|
|
203
203
|
localFiles.push(join(metaDir, String(ref).substring(1)));
|
package/src/lib/columns.js
CHANGED
package/src/lib/config.js
CHANGED
|
@@ -66,7 +66,7 @@ export async function readLegacyConfig() {
|
|
|
66
66
|
|
|
67
67
|
export async function initConfig(domain) {
|
|
68
68
|
await mkdir(dboDir(), { recursive: true });
|
|
69
|
-
await writeFile(configPath(), JSON.stringify({ domain }, null, 2) + '\n');
|
|
69
|
+
await writeFile(configPath(), JSON.stringify({ domain, dependencies: ['_system'] }, null, 2) + '\n');
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export async function saveCredentials(username) {
|
|
@@ -167,6 +167,88 @@ export async function updateConfigWithApp({ AppID, AppUID, AppName, AppShortName
|
|
|
167
167
|
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// ─── Dependency helpers ───────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the dependencies array from .dbo/config.json.
|
|
174
|
+
* Returns ["_system"] if the key is absent.
|
|
175
|
+
*/
|
|
176
|
+
export async function getDependencies() {
|
|
177
|
+
try {
|
|
178
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
179
|
+
const config = JSON.parse(raw);
|
|
180
|
+
const deps = config.dependencies;
|
|
181
|
+
if (!Array.isArray(deps)) return ['_system'];
|
|
182
|
+
if (!deps.includes('_system')) deps.unshift('_system');
|
|
183
|
+
return deps;
|
|
184
|
+
} catch {
|
|
185
|
+
return ['_system'];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Merge new short-names into the dependencies array (union, no duplicates).
|
|
191
|
+
* Persists the result to .dbo/config.json.
|
|
192
|
+
*/
|
|
193
|
+
export async function mergeDependencies(shortnames) {
|
|
194
|
+
await mkdir(dboDir(), { recursive: true });
|
|
195
|
+
let existing = {};
|
|
196
|
+
try {
|
|
197
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
198
|
+
} catch { /* no config */ }
|
|
199
|
+
const current = Array.isArray(existing.dependencies) ? existing.dependencies : ['_system'];
|
|
200
|
+
for (const s of shortnames) {
|
|
201
|
+
if (s && !current.includes(s)) current.push(s);
|
|
202
|
+
}
|
|
203
|
+
if (!current.includes('_system')) current.unshift('_system');
|
|
204
|
+
existing.dependencies = current;
|
|
205
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
206
|
+
return current;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Replace the full dependencies array in .dbo/config.json.
|
|
211
|
+
* Always ensures _system is present.
|
|
212
|
+
*/
|
|
213
|
+
export async function setDependencies(shortnames) {
|
|
214
|
+
await mkdir(dboDir(), { recursive: true });
|
|
215
|
+
let existing = {};
|
|
216
|
+
try {
|
|
217
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
218
|
+
} catch { /* no config */ }
|
|
219
|
+
const deps = [...new Set(['_system', ...shortnames.filter(Boolean)])];
|
|
220
|
+
existing.dependencies = deps;
|
|
221
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get dependencyLastUpdated.<shortname> from .dbo/config.json.
|
|
226
|
+
* Returns null if absent.
|
|
227
|
+
*/
|
|
228
|
+
export async function getDependencyLastUpdated(shortname) {
|
|
229
|
+
try {
|
|
230
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
231
|
+
const config = JSON.parse(raw);
|
|
232
|
+
return (config.dependencyLastUpdated && config.dependencyLastUpdated[shortname]) || null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Set dependencyLastUpdated.<shortname> in .dbo/config.json.
|
|
240
|
+
*/
|
|
241
|
+
export async function setDependencyLastUpdated(shortname, timestamp) {
|
|
242
|
+
await mkdir(dboDir(), { recursive: true });
|
|
243
|
+
let existing = {};
|
|
244
|
+
try {
|
|
245
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
246
|
+
} catch { /* no config */ }
|
|
247
|
+
if (!existing.dependencyLastUpdated) existing.dependencyLastUpdated = {};
|
|
248
|
+
existing.dependencyLastUpdated[shortname] = timestamp;
|
|
249
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
170
252
|
/**
|
|
171
253
|
* Load app-related fields from .dbo/config.json.
|
|
172
254
|
*/
|
package/src/lib/delta.js
CHANGED
|
@@ -99,9 +99,10 @@ export async function compareFileContent(filePath, baselineValue) {
|
|
|
99
99
|
*
|
|
100
100
|
* @param {string} metaPath - Path to metadata.json file
|
|
101
101
|
* @param {Object} baseline - The baseline JSON
|
|
102
|
+
* @param {string} [serverTz] - Server timezone for date parsing (e.g. 'America/Los_Angeles')
|
|
102
103
|
* @returns {Promise<string[]>} - Array of changed column names
|
|
103
104
|
*/
|
|
104
|
-
export async function detectChangedColumns(metaPath, baseline) {
|
|
105
|
+
export async function detectChangedColumns(metaPath, baseline, serverTz) {
|
|
105
106
|
// Load current metadata
|
|
106
107
|
const metaRaw = await readFile(metaPath, 'utf8');
|
|
107
108
|
const metadata = JSON.parse(metaRaw);
|
|
@@ -174,7 +175,7 @@ export async function detectChangedColumns(metaPath, baseline) {
|
|
|
174
175
|
try {
|
|
175
176
|
const mediaStat = await stat(mediaPath);
|
|
176
177
|
const baselineDate = baselineEntry?._LastUpdated
|
|
177
|
-
? parseServerDate(baselineEntry._LastUpdated)
|
|
178
|
+
? parseServerDate(baselineEntry._LastUpdated, serverTz)
|
|
178
179
|
: null;
|
|
179
180
|
if (baselineDate) {
|
|
180
181
|
// Media file modified after baseline sync point = local change
|
package/src/lib/dependencies.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dependency management for entity synchronization.
|
|
3
|
-
*
|
|
2
|
+
* Dependency management for entity synchronization and app dependency cloning.
|
|
3
|
+
* - Entity ordering: ensures children are processed before parents for referential integrity.
|
|
4
|
+
* - App dependencies: auto-clone related apps into .dbo/dependencies/<shortname>/.
|
|
4
5
|
*/
|
|
5
6
|
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { mkdir, symlink, access, readFile, writeFile } from 'fs/promises';
|
|
9
|
+
import { join, resolve, relative, sep } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { DboClient } from './client.js';
|
|
12
|
+
import { log } from './logger.js';
|
|
13
|
+
import {
|
|
14
|
+
getDependencies, mergeDependencies,
|
|
15
|
+
getDependencyLastUpdated, setDependencyLastUpdated,
|
|
16
|
+
loadConfig,
|
|
17
|
+
} from './config.js';
|
|
18
|
+
import { sanitizeFilename } from '../commands/clone.js';
|
|
19
|
+
|
|
6
20
|
/**
|
|
7
21
|
* Entity dependency hierarchy.
|
|
8
22
|
* Lower levels must be processed before higher levels.
|
|
@@ -129,3 +143,204 @@ export function sortEntriesByUid(entries) {
|
|
|
129
143
|
}
|
|
130
144
|
return entries.slice().sort((a, b) => (a.UID || '').localeCompare(b.UID || ''));
|
|
131
145
|
}
|
|
146
|
+
|
|
147
|
+
// ─── App Dependency Cloning ───────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Normalize the app.json Dependencies column into a string[].
|
|
151
|
+
*/
|
|
152
|
+
export function parseDependenciesColumn(value) {
|
|
153
|
+
if (!value) return [];
|
|
154
|
+
if (typeof value === 'string') {
|
|
155
|
+
return value.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
return value.map(s => String(s).trim().toLowerCase()).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === 'object') {
|
|
161
|
+
return Object.keys(value).map(k => k.trim().toLowerCase()).filter(Boolean);
|
|
162
|
+
}
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create symlinks for credentials.json and cookies.txt from parent into checkout.
|
|
168
|
+
*/
|
|
169
|
+
export async function symlinkCredentials(parentDboDir, checkoutDboDir) {
|
|
170
|
+
await mkdir(checkoutDboDir, { recursive: true });
|
|
171
|
+
for (const filename of ['credentials.json', 'cookies.txt']) {
|
|
172
|
+
const src = join(parentDboDir, filename);
|
|
173
|
+
const dest = join(checkoutDboDir, filename);
|
|
174
|
+
try { await access(src); } catch { continue; }
|
|
175
|
+
try { await access(dest); continue; } catch { /* proceed */ }
|
|
176
|
+
try {
|
|
177
|
+
await symlink(resolve(src), dest);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err.code !== 'EEXIST') log.warn(` Could not symlink ${filename}: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run dbo CLI as a child process in a given directory.
|
|
186
|
+
*/
|
|
187
|
+
export function execDboInDir(dir, args, options = {}) {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const dboBin = fileURLToPath(new URL('../../bin/dbo.js', import.meta.url));
|
|
190
|
+
const quiet = options.quiet || false;
|
|
191
|
+
const child = spawn(process.execPath, [dboBin, ...args], {
|
|
192
|
+
cwd: dir,
|
|
193
|
+
stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
194
|
+
env: process.env,
|
|
195
|
+
});
|
|
196
|
+
let stderr = '';
|
|
197
|
+
if (quiet) {
|
|
198
|
+
child.stdout?.resume(); // drain stdout
|
|
199
|
+
child.stderr?.on('data', chunk => { stderr += chunk; });
|
|
200
|
+
}
|
|
201
|
+
child.on('close', code => {
|
|
202
|
+
if (code === 0) resolve();
|
|
203
|
+
else reject(new Error(stderr.trim() || `dbo ${args.join(' ')} exited with code ${code}`));
|
|
204
|
+
});
|
|
205
|
+
child.on('error', reject);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Returns true if the dependency needs a fresh clone.
|
|
211
|
+
*/
|
|
212
|
+
export async function checkDependencyStaleness(shortname, options = {}) {
|
|
213
|
+
const stored = await getDependencyLastUpdated(shortname);
|
|
214
|
+
if (!stored) return true; // Never cloned
|
|
215
|
+
|
|
216
|
+
const { domain } = await loadConfig();
|
|
217
|
+
const effectiveDomain = options.domain || domain;
|
|
218
|
+
const client = new DboClient({ domain: effectiveDomain, verbose: options.verbose });
|
|
219
|
+
|
|
220
|
+
const dateStr = stored.substring(0, 10); // YYYY-MM-DD from ISO string
|
|
221
|
+
const result = await client.get(
|
|
222
|
+
`/api/app/object/${encodeURIComponent(shortname)}[_LastUpdated]?UpdatedAfter=${dateStr}`
|
|
223
|
+
);
|
|
224
|
+
if (!result.ok || !result.data) return false; // Can't determine — assume fresh
|
|
225
|
+
|
|
226
|
+
const serverTs = result.data._LastUpdated;
|
|
227
|
+
if (!serverTs) return false;
|
|
228
|
+
return new Date(serverTs) > new Date(stored);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Sync dependency apps into .dbo/dependencies/<shortname>/.
|
|
233
|
+
*
|
|
234
|
+
* @param {object} options
|
|
235
|
+
* @param {string} [options.domain] - Override domain
|
|
236
|
+
* @param {boolean} [options.force] - Bypass staleness check
|
|
237
|
+
* @param {boolean} [options.schema] - Bypass staleness check (--schema flag)
|
|
238
|
+
* @param {boolean} [options.verbose]
|
|
239
|
+
* @param {string} [options.systemSchemaPath] - Absolute path to schema.json for _system fast-clone
|
|
240
|
+
* @param {string[]} [options.only] - Only sync these short-names
|
|
241
|
+
* @param {Function} [options._execOverride] - Override execDboInDir for testing
|
|
242
|
+
*/
|
|
243
|
+
export async function syncDependencies(options = {}) {
|
|
244
|
+
// Recursive guard: don't run inside a checkout directory
|
|
245
|
+
const cwd = process.cwd();
|
|
246
|
+
if (cwd.includes(`${sep}.dbo${sep}dependencies${sep}`)) {
|
|
247
|
+
log.dim(' Skipping dependency sync (inside a checkout directory)');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const deps = options.only
|
|
252
|
+
? [...new Set(['_system', ...options.only])]
|
|
253
|
+
: await getDependencies();
|
|
254
|
+
|
|
255
|
+
const parentDboDir = join(cwd, '.dbo');
|
|
256
|
+
const depsRoot = join(parentDboDir, 'dependencies');
|
|
257
|
+
|
|
258
|
+
const forceAll = !!(options.force || options.schema);
|
|
259
|
+
const execFn = options._execOverride || execDboInDir;
|
|
260
|
+
|
|
261
|
+
const synced = [];
|
|
262
|
+
const skipped = [];
|
|
263
|
+
const failed = [];
|
|
264
|
+
|
|
265
|
+
const spinner = log.spinner(`Syncing dependencies [${deps.join(', ')}]`);
|
|
266
|
+
|
|
267
|
+
for (const raw of deps) {
|
|
268
|
+
const shortname = sanitizeFilename(raw.toLowerCase().trim());
|
|
269
|
+
if (!shortname) continue;
|
|
270
|
+
|
|
271
|
+
spinner.update(`Syncing dependency: ${shortname}`);
|
|
272
|
+
|
|
273
|
+
const checkoutDir = join(depsRoot, shortname);
|
|
274
|
+
const checkoutDboDir = join(checkoutDir, '.dbo');
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// 1. Create checkout dir + minimal config
|
|
278
|
+
await mkdir(checkoutDboDir, { recursive: true });
|
|
279
|
+
const minConfigPath = join(checkoutDboDir, 'config.json');
|
|
280
|
+
let configExists = false;
|
|
281
|
+
try { await access(minConfigPath); configExists = true; } catch {}
|
|
282
|
+
if (!configExists) {
|
|
283
|
+
const { domain } = await loadConfig();
|
|
284
|
+
const effectiveDomain = options.domain || domain;
|
|
285
|
+
await writeFile(minConfigPath, JSON.stringify({ domain: effectiveDomain }, null, 2) + '\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 2. Symlink credentials
|
|
289
|
+
await symlinkCredentials(parentDboDir, checkoutDboDir);
|
|
290
|
+
|
|
291
|
+
// 3. Staleness check (unless --force or --schema)
|
|
292
|
+
if (!forceAll) {
|
|
293
|
+
let isStale = true;
|
|
294
|
+
try {
|
|
295
|
+
isStale = await checkDependencyStaleness(shortname, options);
|
|
296
|
+
} catch {
|
|
297
|
+
// Network unavailable — assume stale to attempt clone
|
|
298
|
+
}
|
|
299
|
+
if (!isStale) {
|
|
300
|
+
skipped.push(shortname);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 4. Run the clone (quiet — suppress child process output)
|
|
306
|
+
if (shortname === '_system' && options.systemSchemaPath) {
|
|
307
|
+
const relPath = relative(checkoutDir, options.systemSchemaPath);
|
|
308
|
+
await execFn(checkoutDir, ['clone', relPath, '--force', '--yes', '--no-deps'], { quiet: true });
|
|
309
|
+
} else {
|
|
310
|
+
await execFn(checkoutDir, ['clone', '--app', shortname, '--force', '--yes', '--no-deps'], { quiet: true });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 5. Read _LastUpdated from checkout's app.json and persist
|
|
314
|
+
try {
|
|
315
|
+
const appJsonPath = join(checkoutDir, 'app.json');
|
|
316
|
+
const appJson = JSON.parse(await readFile(appJsonPath, 'utf8'));
|
|
317
|
+
const ts = appJson._LastUpdated || appJson.LastUpdated || null;
|
|
318
|
+
if (ts) await setDependencyLastUpdated(shortname, ts);
|
|
319
|
+
} catch { /* can't read _LastUpdated — that's OK */ }
|
|
320
|
+
|
|
321
|
+
synced.push(shortname);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
failed.push(shortname);
|
|
324
|
+
if (options.verbose) log.warn(` Dependency "${shortname}" failed: ${err.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Stop spinner and print summary
|
|
329
|
+
const parts = [];
|
|
330
|
+
if (synced.length > 0) parts.push(`synced [${synced.join(', ')}]`);
|
|
331
|
+
if (skipped.length > 0) parts.push(`up to date [${skipped.join(', ')}]`);
|
|
332
|
+
if (failed.length > 0) parts.push(`failed [${failed.join(', ')}]`);
|
|
333
|
+
|
|
334
|
+
if (failed.length > 0 && synced.length === 0 && skipped.length === 0) {
|
|
335
|
+
spinner.stop(null);
|
|
336
|
+
log.warn(`Dependencies ${parts.join(', ')}`);
|
|
337
|
+
} else {
|
|
338
|
+
const summary = `Dependencies: ${parts.join(', ')}`;
|
|
339
|
+
spinner.stop(null);
|
|
340
|
+
if (failed.length > 0) {
|
|
341
|
+
log.warn(summary);
|
|
342
|
+
} else {
|
|
343
|
+
log.success(summary);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
package/src/lib/diff.js
CHANGED
|
@@ -208,15 +208,13 @@ export async function hasLocalModifications(metaPath, config = {}) {
|
|
|
208
208
|
if (!syncDate) return false;
|
|
209
209
|
const syncTime = syncDate.getTime();
|
|
210
210
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return true;
|
|
215
|
-
}
|
|
211
|
+
// Skip metadata self-check: metadata files are managed by the CLI and
|
|
212
|
+
// their mtimes get bumped by migrations, interrupted clones, etc.
|
|
213
|
+
// Only check companion content/media files for actual user edits.
|
|
216
214
|
|
|
217
215
|
// Check content files
|
|
218
216
|
const metaDir = dirname(metaPath);
|
|
219
|
-
const contentCols = meta._contentColumns || [];
|
|
217
|
+
const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
|
|
220
218
|
|
|
221
219
|
// Load baseline for content comparison fallback (build tools can
|
|
222
220
|
// rewrite files with identical content, bumping mtime without any
|
|
@@ -497,7 +495,7 @@ export async function compareRecord(metaPath, config, serverRecordsMap) {
|
|
|
497
495
|
|
|
498
496
|
const metaDir = dirname(metaPath);
|
|
499
497
|
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
500
|
-
const contentCols = localMeta._contentColumns || [];
|
|
498
|
+
const contentCols = localMeta._companionReferenceColumns || localMeta._contentColumns || [];
|
|
501
499
|
const fieldDiffs = [];
|
|
502
500
|
|
|
503
501
|
// Compare content file columns
|
|
@@ -530,7 +528,7 @@ export async function compareRecord(metaPath, config, serverRecordsMap) {
|
|
|
530
528
|
}
|
|
531
529
|
|
|
532
530
|
// Compare metadata fields (non-content, non-system)
|
|
533
|
-
const skipFields = new Set(['_entity', '_contentColumns', '_mediaFile', 'children']);
|
|
531
|
+
const skipFields = new Set(['_entity', '_contentColumns', '_companionReferenceColumns', '_mediaFile', 'children']);
|
|
534
532
|
for (const [key, serverVal] of Object.entries(serverRecord)) {
|
|
535
533
|
if (skipFields.has(key)) continue;
|
|
536
534
|
if (contentCols.includes(key)) continue; // Already handled above
|
|
@@ -607,7 +605,7 @@ export async function compareRecord(metaPath, config, serverRecordsMap) {
|
|
|
607
605
|
export async function applyServerChanges(diffResult, acceptedFields, config) {
|
|
608
606
|
const { metaPath, serverRecord, localMeta, fieldDiffs } = diffResult;
|
|
609
607
|
const metaDir = dirname(metaPath);
|
|
610
|
-
const contentCols = new Set(localMeta._contentColumns || []);
|
|
608
|
+
const contentCols = new Set(localMeta._companionReferenceColumns || localMeta._contentColumns || []);
|
|
611
609
|
let updatedMeta = { ...localMeta };
|
|
612
610
|
const filesToTimestamp = [metaPath];
|
|
613
611
|
|
|
@@ -801,7 +799,7 @@ export async function inlineDiffAndMerge(serverRow, metaPath, config, options =
|
|
|
801
799
|
|
|
802
800
|
const metaDir = dirname(metaPath);
|
|
803
801
|
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
804
|
-
const contentCols = localMeta._contentColumns || [];
|
|
802
|
+
const contentCols = localMeta._companionReferenceColumns || localMeta._contentColumns || [];
|
|
805
803
|
const fieldDiffs = [];
|
|
806
804
|
|
|
807
805
|
// Compare content columns
|
|
@@ -837,7 +835,7 @@ export async function inlineDiffAndMerge(serverRow, metaPath, config, options =
|
|
|
837
835
|
}
|
|
838
836
|
|
|
839
837
|
// Compare metadata fields
|
|
840
|
-
const skipFields = new Set(['_entity', '_contentColumns', '_mediaFile', 'children']);
|
|
838
|
+
const skipFields = new Set(['_entity', '_contentColumns', '_companionReferenceColumns', '_mediaFile', 'children']);
|
|
841
839
|
for (const [key, serverVal] of Object.entries(serverRow)) {
|
|
842
840
|
if (skipFields.has(key)) continue;
|
|
843
841
|
if (contentCols.includes(key)) continue;
|