@dboio/cli 0.15.3 → 0.17.0

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.
@@ -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');
@@ -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
- // Check if data expressions include AppID; if not and config has one, prompt
63
- // Skip AppID prompt for delete-only submissions deletes don't need it
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
@@ -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));
@@ -20,7 +20,7 @@ import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '..
20
20
  import { ensureTrashIcon, setFileTag } from '../lib/tagging.js';
21
21
  import { checkToeStepping } from '../lib/toe-stepping.js';
22
22
  import { runPendingMigrations } from '../lib/migrations.js';
23
- // AUTO-ADD DISABLED: import { findUnaddedFiles, detectBinFile, submitAdd } from './add.js';
23
+ // AUTO-ADD DISABLED: import { findUnaddedFiles, detectBinFile, submitAdd } from '../lib/insert.js';
24
24
 
25
25
  /**
26
26
  * Resolve an @reference file path to an absolute filesystem path.
@@ -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
- _contentColumns: ['Content'],
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];
@@ -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)));
@@ -14,6 +14,7 @@ const SKIP_COLUMNS = new Set([
14
14
  '_LastUpdatedTicketID',
15
15
  '_entity',
16
16
  '_contentColumns',
17
+ '_companionReferenceColumns',
17
18
  ]);
18
19
 
19
20
  /**
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
@@ -1,8 +1,22 @@
1
1
  /**
2
- * Dependency management for entity synchronization.
3
- * Ensures children are processed before parents to maintain referential integrity.
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
- // Check if metadata.json itself was edited since last sync
212
- const metaStat = await stat(metaPath);
213
- if (metaStat.mtime.getTime() > syncTime + 2000) {
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;