@dboio/cli 0.15.2 → 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.
@@ -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;
@@ -213,7 +213,7 @@ export async function findMetadataForCompanion(companionPath) {
213
213
  const metaPath = join(dir, entry);
214
214
  try {
215
215
  const meta = JSON.parse(await readFile(metaPath, 'utf8'));
216
- const cols = [...(meta._contentColumns || [])];
216
+ const cols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
217
217
  if (meta._mediaFile) cols.push('_mediaFile');
218
218
  for (const col of cols) {
219
219
  const ref = meta[col];
@@ -280,7 +280,7 @@ export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, se
280
280
  if (serverTz && lastUpdated) {
281
281
  const { setFileTimestamps } = await import('./timestamps.js');
282
282
  try { await setFileTimestamps(newMetaPath, lastUpdated, lastUpdated, serverTz); } catch {}
283
- const contentCols = [...(meta._contentColumns || [])];
283
+ const contentCols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
284
284
  if (meta._mediaFile) contentCols.push('_mediaFile');
285
285
  for (const col of contentCols) {
286
286
  const ref = updatedMeta[col];
package/src/lib/ignore.js CHANGED
@@ -16,6 +16,7 @@ const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
16
16
  .dboignore
17
17
  *.dboio.json
18
18
  app.json
19
+ .dbo/dependencies/
19
20
 
20
21
  # Editor / IDE / OS
21
22
  .DS_Store
package/src/lib/logger.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
 
3
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4
+
3
5
  export const log = {
4
6
  info(msg) { console.log(chalk.blue('ℹ'), msg); },
5
7
  success(msg) { console.log(chalk.green('✓'), msg); },
@@ -9,4 +11,37 @@ export const log = {
9
11
  plain(msg) { console.log(msg); },
10
12
  verbose(msg) { console.log(chalk.dim(' →'), chalk.dim(msg)); },
11
13
  label(label, value) { console.log(chalk.dim(` ${label}:`), value); },
14
+
15
+ /**
16
+ * Start a spinner with a message. Returns { stop(finalMsg) }.
17
+ * The spinner writes to stderr so it doesn't pollute piped output.
18
+ */
19
+ spinner(msg) {
20
+ let i = 0;
21
+ const stream = process.stderr;
22
+ const isTTY = stream.isTTY;
23
+ if (!isTTY) {
24
+ // Non-TTY: just print the message once, return a no-op stop
25
+ console.log(chalk.dim(msg));
26
+ return { update() {}, stop(final) { if (final) console.log(final); } };
27
+ }
28
+ const render = () => {
29
+ const frame = chalk.cyan(SPINNER_FRAMES[i % SPINNER_FRAMES.length]);
30
+ stream.clearLine(0);
31
+ stream.cursorTo(0);
32
+ stream.write(`${frame} ${chalk.dim(msg)}`);
33
+ i++;
34
+ };
35
+ render();
36
+ const timer = setInterval(render, 80);
37
+ return {
38
+ update(newMsg) { msg = newMsg; },
39
+ stop(final) {
40
+ clearInterval(timer);
41
+ stream.clearLine(0);
42
+ stream.cursorTo(0);
43
+ if (final) console.log(final);
44
+ },
45
+ };
46
+ },
12
47
  };