@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.
- 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 +390 -157
- 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 +29 -11
- 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 +31 -7
- 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/ticketing.js +18 -2
- package/src/lib/toe-stepping.js +9 -6
- package/src/migrations/007-natural-entity-companion-filenames.js +5 -2
- package/src/migrations/009-fix-media-collision-metadata-names.js +161 -0
- package/src/migrations/010-delete-paren-media-orphans.js +61 -0
- package/src/migrations/011-schema-driven-metadata.js +120 -0
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;
|
package/src/lib/filenames.js
CHANGED
|
@@ -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
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
|
};
|