@dboio/cli 0.19.7 → 0.20.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.
- package/README.md +18 -12
- package/bin/dbo.js +5 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +39 -12
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +18 -12
- package/plugins/claude/dbo/skills/cookbook/SKILL.md +13 -3
- package/plugins/claude/dbo/skills/white-paper/SKILL.md +49 -8
- package/plugins/claude/dbo/skills/white-paper/references/api-reference.md +1 -1
- package/plugins/claude/track/.claude-plugin/plugin.json +1 -1
- package/src/commands/adopt.js +4 -3
- package/src/commands/clone.js +245 -55
- package/src/commands/init.js +2 -2
- package/src/commands/input.js +2 -2
- package/src/commands/login.js +3 -3
- package/src/commands/push.js +78 -18
- package/src/commands/status.js +15 -7
- package/src/lib/config.js +91 -11
- package/src/lib/filenames.js +54 -66
- package/src/lib/ignore.js +3 -0
- package/src/lib/insert.js +29 -45
- package/src/lib/structure.js +23 -8
- package/src/lib/ticketing.js +9 -8
- package/src/migrations/008-metadata-uid-in-suffix.js +4 -2
- package/src/migrations/009-fix-media-collision-metadata-names.js +9 -3
- package/src/migrations/013-remove-uid-from-meta-filenames.js +117 -0
- package/src/migrations/014-entity-dir-to-data-source.js +68 -0
package/src/commands/clone.js
CHANGED
|
@@ -3,13 +3,13 @@ import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat,
|
|
|
3
3
|
import { join, basename, extname, dirname } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { DboClient } from '../lib/client.js';
|
|
6
|
-
import { loadConfig, updateConfigWithApp, updateConfigUserMedia, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement, loadRootContentFiles } from '../lib/config.js';
|
|
6
|
+
import { loadConfig, updateConfigWithApp, updateConfigUserMedia, loadClonePlacement, saveClonePlacement, ensureGitignore, DEFAULT_GITIGNORE_ENTRIES, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement, loadRootContentFiles } from '../lib/config.js';
|
|
7
7
|
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath, resolveFieldValue } from '../lib/structure.js';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
9
|
import { buildUidFilename, buildContentFileName, buildMetaFilename, isMetadataFile, parseMetaFilename, stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
|
|
10
10
|
import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
|
|
11
11
|
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache, findMetadataFiles } from '../lib/diff.js';
|
|
12
|
-
import { loadIgnore } from '../lib/ignore.js';
|
|
12
|
+
import { loadIgnore, getDefaultFileContent as getDboignoreDefaultContent } from '../lib/ignore.js';
|
|
13
13
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
14
14
|
import { applyTrashIcon, ensureTrashIcon, tagProjectFiles } from '../lib/tagging.js';
|
|
15
15
|
import { loadMetadataSchema, saveMetadataSchema, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord, generateMetadataFromSchema, parseReferenceExpression, mergeDescriptorSchemaFromDependencies } from '../lib/metadata-schema.js';
|
|
@@ -76,6 +76,37 @@ async function fileExists(path) {
|
|
|
76
76
|
try { await access(path); return true; } catch { return false; }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a metadata file path with collision detection.
|
|
81
|
+
* Content records are processed before output, so they naturally claim the unsuffixed
|
|
82
|
+
* name. This function ensures output (and any late-arriving entity) gets a numbered
|
|
83
|
+
* suffix when the clean name is already owned by a different UID.
|
|
84
|
+
*
|
|
85
|
+
* Algorithm: try name.metadata.json, then name-1.metadata.json, name-2.metadata.json, …
|
|
86
|
+
* until we find a slot that either doesn't exist or is owned by the same UID.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} dir - Directory to check
|
|
89
|
+
* @param {string} naturalBase - Natural base name (no .metadata.json suffix)
|
|
90
|
+
* @param {string} uid - UID of the record being written
|
|
91
|
+
* @returns {Promise<string>} - Absolute path to use for the metadata file
|
|
92
|
+
*/
|
|
93
|
+
async function resolveMetaCollision(dir, naturalBase, uid) {
|
|
94
|
+
for (let i = 0; i < 1000; i++) {
|
|
95
|
+
const candidate = i === 0
|
|
96
|
+
? `${naturalBase}.metadata.json`
|
|
97
|
+
: `${naturalBase}-${i}.metadata.json`;
|
|
98
|
+
const fullPath = join(dir, candidate);
|
|
99
|
+
try {
|
|
100
|
+
const existing = JSON.parse(await readFile(fullPath, 'utf8'));
|
|
101
|
+
if (!existing.UID || existing.UID === uid) return fullPath; // same or new record
|
|
102
|
+
// Slot owned by a different UID — try next
|
|
103
|
+
} catch {
|
|
104
|
+
return fullPath; // file doesn't exist — use it
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return join(dir, `${naturalBase}.metadata.json`); // fallback (should never reach here)
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
const WILL_DELETE_PREFIX = '__WILL_DELETE__';
|
|
80
111
|
|
|
81
112
|
function isWillDeleteFile(filename) {
|
|
@@ -322,8 +353,8 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
|
|
|
322
353
|
const uid = String(record.UID || record._id || 'untitled');
|
|
323
354
|
// Companion: natural name, no UID
|
|
324
355
|
const filename = sanitizeFilename(buildContentFileName(record, uid));
|
|
325
|
-
// Metadata: name.metadata
|
|
326
|
-
const metaPath = join(dir, buildMetaFilename(name
|
|
356
|
+
// Metadata: name.metadata.json
|
|
357
|
+
const metaPath = join(dir, buildMetaFilename(name));
|
|
327
358
|
|
|
328
359
|
return { dir, filename, metaPath };
|
|
329
360
|
}
|
|
@@ -352,10 +383,9 @@ export function resolveMediaPaths(record, structure) {
|
|
|
352
383
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
353
384
|
if (!dir) dir = BINS_DIR;
|
|
354
385
|
|
|
355
|
-
// Metadata: name.ext.metadata
|
|
356
|
-
const uid = String(record.UID || record._id || 'untitled');
|
|
386
|
+
// Metadata: name.ext.metadata.json
|
|
357
387
|
const naturalMediaBase = `${name}.${ext}`;
|
|
358
|
-
const metaPath = join(dir, buildMetaFilename(naturalMediaBase
|
|
388
|
+
const metaPath = join(dir, buildMetaFilename(naturalMediaBase));
|
|
359
389
|
|
|
360
390
|
return { dir, filename: companionFilename, metaPath };
|
|
361
391
|
}
|
|
@@ -397,8 +427,7 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
|
397
427
|
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
398
428
|
}
|
|
399
429
|
|
|
400
|
-
const
|
|
401
|
-
const metaPath = join(dirName, buildMetaFilename(name, uid));
|
|
430
|
+
const metaPath = join(dirName, buildMetaFilename(name));
|
|
402
431
|
return { dir: dirName, name, metaPath };
|
|
403
432
|
}
|
|
404
433
|
|
|
@@ -1197,11 +1226,25 @@ export async function performClone(source, options = {}) {
|
|
|
1197
1226
|
}
|
|
1198
1227
|
}
|
|
1199
1228
|
|
|
1229
|
+
// Save AppShortName to config before writing the metadata schema so that
|
|
1230
|
+
// metadataSchemaPath() resolves to <shortname>.metadata_schema.json rather
|
|
1231
|
+
// than falling back to the generic app.metadata_schema.json. Without this,
|
|
1232
|
+
// processExtensionEntries() later loads null (wrong file) and descriptor
|
|
1233
|
+
// sub-directories + companion @reference entries are lost.
|
|
1234
|
+
if (!options.pullMode && appJson?.ShortName) {
|
|
1235
|
+
await updateConfigWithApp({ AppShortName: appJson.ShortName });
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1200
1238
|
// Regenerate metadata_schema.json for any new entity types
|
|
1201
1239
|
if (schema) {
|
|
1202
1240
|
const existing = await loadMetadataSchema();
|
|
1203
1241
|
const updated = generateMetadataFromSchema(schema, existing ?? {});
|
|
1204
1242
|
await saveMetadataSchema(updated);
|
|
1243
|
+
// Remove orphaned app.metadata_schema.json left by previous runs that wrote
|
|
1244
|
+
// the schema before AppShortName was saved (entity entries are regenerated above).
|
|
1245
|
+
if (appJson?.ShortName && appJson.ShortName !== 'app') {
|
|
1246
|
+
try { await unlink(join('.app', 'app.metadata_schema.json')); } catch { /* not present */ }
|
|
1247
|
+
}
|
|
1205
1248
|
}
|
|
1206
1249
|
|
|
1207
1250
|
// Domain change detection
|
|
@@ -1222,7 +1265,7 @@ export async function performClone(source, options = {}) {
|
|
|
1222
1265
|
|
|
1223
1266
|
// Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
|
|
1224
1267
|
if (!isDependencyCheckout()) {
|
|
1225
|
-
await ensureGitignore(
|
|
1268
|
+
await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
|
|
1226
1269
|
}
|
|
1227
1270
|
|
|
1228
1271
|
// Step 2: Update .app/config.json (skip in pull mode — config already set)
|
|
@@ -1415,7 +1458,7 @@ export async function performClone(source, options = {}) {
|
|
|
1415
1458
|
|
|
1416
1459
|
// Step 5a: Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to project root.
|
|
1417
1460
|
// Also fixes the duplicate bug: relocates companions from lib/bins/app/ to root and rewrites metadata.
|
|
1418
|
-
if (
|
|
1461
|
+
if (!entityFilter || entityFilter.has('content')) {
|
|
1419
1462
|
await writeRootContentFiles(appJson, contentRefs);
|
|
1420
1463
|
}
|
|
1421
1464
|
|
|
@@ -1978,14 +2021,15 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1978
2021
|
}
|
|
1979
2022
|
|
|
1980
2023
|
// Resolve name collisions: second+ record with same name gets -1, -2, etc.
|
|
2024
|
+
// Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
|
|
1981
2025
|
const uid = record.UID || 'untitled';
|
|
1982
|
-
const nameKey = name;
|
|
2026
|
+
const nameKey = name.toLowerCase();
|
|
1983
2027
|
const count = usedNames.get(nameKey) || 0;
|
|
1984
2028
|
usedNames.set(nameKey, count + 1);
|
|
1985
2029
|
if (count > 0) name = `${name}-${count}`;
|
|
1986
2030
|
|
|
1987
|
-
// Metadata: name.metadata
|
|
1988
|
-
const metaPath = join(dirName, buildMetaFilename(name
|
|
2031
|
+
// Metadata: name.metadata.json; companion files use natural name
|
|
2032
|
+
const metaPath = join(dirName, buildMetaFilename(name));
|
|
1989
2033
|
|
|
1990
2034
|
// Legacy detection: rename old-format metadata files to new convention
|
|
1991
2035
|
const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
|
|
@@ -2217,7 +2261,7 @@ function parseFormControlCode(string5) {
|
|
|
2217
2261
|
if (codeStr) {
|
|
2218
2262
|
for (const pair of codeStr.split(',')) {
|
|
2219
2263
|
const [col, ext] = pair.split('|');
|
|
2220
|
-
if (col?.trim()
|
|
2264
|
+
if (col?.trim()) colToExt.set(col.trim(), ext?.trim().toLowerCase() || 'md');
|
|
2221
2265
|
}
|
|
2222
2266
|
}
|
|
2223
2267
|
const titleStr = params.get('form-control-title');
|
|
@@ -2284,9 +2328,10 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
|
|
|
2284
2328
|
const { colToExt, colToTitle } = parseFormControlCode(string5);
|
|
2285
2329
|
if (colToExt.size === 0) continue;
|
|
2286
2330
|
|
|
2287
|
-
// Only
|
|
2331
|
+
// Only skip if the existing entry already has @reference expressions;
|
|
2332
|
+
// plain-column entries (seeded without form-control-code) should be overwritten
|
|
2288
2333
|
const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
|
|
2289
|
-
if (existing) continue;
|
|
2334
|
+
if (existing?.some(e => e.includes('@reference'))) continue;
|
|
2290
2335
|
|
|
2291
2336
|
const refEntries = [];
|
|
2292
2337
|
for (const [col, ext] of colToExt) {
|
|
@@ -2469,8 +2514,6 @@ function guessExtensionForDescriptor(descriptor, columnName) {
|
|
|
2469
2514
|
* @returns {Promise<'inline'|'root'>}
|
|
2470
2515
|
*/
|
|
2471
2516
|
async function resolveDocumentationPlacement(options) {
|
|
2472
|
-
if (options.yes) return 'inline';
|
|
2473
|
-
|
|
2474
2517
|
const saved = await loadExtensionDocumentationMDPlacement();
|
|
2475
2518
|
if (saved && !options.force && !options.configure) {
|
|
2476
2519
|
log.dim(` Documentation MD placement: ${saved} (saved)`);
|
|
@@ -2478,7 +2521,7 @@ async function resolveDocumentationPlacement(options) {
|
|
|
2478
2521
|
}
|
|
2479
2522
|
|
|
2480
2523
|
let placement;
|
|
2481
|
-
if (options.configure) {
|
|
2524
|
+
if (options.configure && !options.yes) {
|
|
2482
2525
|
const inquirer = (await import('inquirer')).default;
|
|
2483
2526
|
({ placement } = await inquirer.prompt([{
|
|
2484
2527
|
type: 'list', name: 'placement',
|
|
@@ -2520,7 +2563,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2520
2563
|
log.info(`Processing ${entries.length} extension record(s)...`);
|
|
2521
2564
|
|
|
2522
2565
|
// Step A: Pre-pass — build mapping + create directories
|
|
2523
|
-
const metadataSchema = await loadMetadataSchema();
|
|
2566
|
+
const metadataSchema = (await loadMetadataSchema()) ?? {};
|
|
2524
2567
|
const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
|
|
2525
2568
|
|
|
2526
2569
|
// Clear documentation preferences when --force is used with --documentation-only
|
|
@@ -2568,12 +2611,19 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2568
2611
|
}
|
|
2569
2612
|
|
|
2570
2613
|
// Step D: Write files, one group at a time
|
|
2614
|
+
// descriptor_definition must be written before dependent descriptors (e.g. control)
|
|
2571
2615
|
const refs = [];
|
|
2572
2616
|
const bulkAction = { value: null };
|
|
2573
2617
|
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
2574
2618
|
const config = await loadConfig();
|
|
2575
2619
|
|
|
2576
|
-
|
|
2620
|
+
const sortedGroups = [...groups.entries()].sort(([a], [b]) => {
|
|
2621
|
+
if (a === 'descriptor_definition') return -1;
|
|
2622
|
+
if (b === 'descriptor_definition') return 1;
|
|
2623
|
+
return 0;
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
for (const [descriptor, { dir, records }] of sortedGroups) {
|
|
2577
2627
|
const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
|
|
2578
2628
|
const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
|
|
2579
2629
|
const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
|
|
@@ -2593,14 +2643,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2593
2643
|
}
|
|
2594
2644
|
|
|
2595
2645
|
// Resolve name collisions: second+ record with same name gets -1, -2, etc.
|
|
2646
|
+
// Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
|
|
2596
2647
|
const uid = record.UID || 'untitled';
|
|
2597
|
-
const nameKey = name;
|
|
2648
|
+
const nameKey = name.toLowerCase();
|
|
2598
2649
|
const nameCount = usedNames.get(nameKey) || 0;
|
|
2599
2650
|
usedNames.set(nameKey, nameCount + 1);
|
|
2600
2651
|
if (nameCount > 0) name = `${name}-${nameCount}`;
|
|
2601
2652
|
|
|
2602
|
-
// Metadata: name.metadata
|
|
2603
|
-
const metaPath = join(dir, buildMetaFilename(name
|
|
2653
|
+
// Metadata: name.metadata.json; companion files use natural name
|
|
2654
|
+
const metaPath = join(dir, buildMetaFilename(name));
|
|
2604
2655
|
|
|
2605
2656
|
// Legacy detection: rename old-format metadata files to new convention
|
|
2606
2657
|
const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
|
|
@@ -2819,7 +2870,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2819
2870
|
const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
|
|
2820
2871
|
const resolvedName = resolvedFilenames.get(record.UID);
|
|
2821
2872
|
const effectiveMetaPath = resolvedName
|
|
2822
|
-
? join(scanDir, buildMetaFilename(resolvedName
|
|
2873
|
+
? join(scanDir, buildMetaFilename(resolvedName))
|
|
2823
2874
|
: scanMetaPath;
|
|
2824
2875
|
if (!(await fileExists(effectiveMetaPath))) {
|
|
2825
2876
|
const staleMeta = { _entity: 'media', _foreignApp: true };
|
|
@@ -2881,7 +2932,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2881
2932
|
// Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
|
|
2882
2933
|
const resolvedName = resolvedFilenames.get(record.UID);
|
|
2883
2934
|
const effectiveMetaPath = resolvedName
|
|
2884
|
-
? join(scanDir, buildMetaFilename(resolvedName
|
|
2935
|
+
? join(scanDir, buildMetaFilename(resolvedName))
|
|
2885
2936
|
: scanMetaPath;
|
|
2886
2937
|
const scanExists = await fileExists(effectiveMetaPath);
|
|
2887
2938
|
|
|
@@ -2971,12 +3022,11 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2971
3022
|
if (!dir) dir = BINS_DIR;
|
|
2972
3023
|
await mkdir(dir, { recursive: true });
|
|
2973
3024
|
|
|
2974
|
-
// Companion: natural name
|
|
2975
|
-
const uid = String(record.UID || record._id || 'untitled');
|
|
3025
|
+
// Companion: natural name (use collision-resolved override if available)
|
|
2976
3026
|
const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
|
|
2977
3027
|
const filePath = join(dir, finalFilename);
|
|
2978
|
-
// Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata
|
|
2979
|
-
const metaPath = join(dir, buildMetaFilename(finalFilename
|
|
3028
|
+
// Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata.json")
|
|
3029
|
+
const metaPath = join(dir, buildMetaFilename(finalFilename));
|
|
2980
3030
|
// usedNames retained for tracking
|
|
2981
3031
|
const fileKey = `${dir}/${name}.${ext}`;
|
|
2982
3032
|
usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
|
|
@@ -3248,7 +3298,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3248
3298
|
probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
|
|
3249
3299
|
if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
|
|
3250
3300
|
|
|
3251
|
-
const probeMeta = join(probeDir, buildMetaFilename(sanitized
|
|
3301
|
+
const probeMeta = join(probeDir, buildMetaFilename(sanitized));
|
|
3252
3302
|
const raw = await readFile(probeMeta, 'utf8');
|
|
3253
3303
|
const localMeta = JSON.parse(raw);
|
|
3254
3304
|
// Extract extension from Content @reference (e.g. "@Name~uid.html")
|
|
@@ -3339,8 +3389,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3339
3389
|
const uid = String(record.UID || record._id || 'untitled');
|
|
3340
3390
|
// Companion: natural name, no UID (use collision-resolved override if available)
|
|
3341
3391
|
const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
|
|
3342
|
-
// Metadata: name.metadata
|
|
3343
|
-
// usedNames retained for non-UID edge case tracking
|
|
3392
|
+
// Metadata: name.metadata.json; usedNames retained for non-UID edge case tracking
|
|
3344
3393
|
const nameKey = `${dir}/${name}`;
|
|
3345
3394
|
usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
|
|
3346
3395
|
|
|
@@ -3352,7 +3401,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3352
3401
|
);
|
|
3353
3402
|
|
|
3354
3403
|
const filePath = join(dir, fileName);
|
|
3355
|
-
const metaPath = join(dir, buildMetaFilename(name
|
|
3404
|
+
const metaPath = join(dir, buildMetaFilename(name));
|
|
3356
3405
|
|
|
3357
3406
|
// Rename legacy ~UID companion files to natural names if needed
|
|
3358
3407
|
if (await fileExists(metaPath)) {
|
|
@@ -4081,22 +4130,32 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
4081
4130
|
// Build root output filename (natural name, no UID in stem)
|
|
4082
4131
|
const rootBasename = buildOutputFilename('output', output, filenameCols.output);
|
|
4083
4132
|
const rootUid = output.UID || '';
|
|
4084
|
-
|
|
4133
|
+
|
|
4134
|
+
// Resolve metadata path with collision detection (content records have priority over output).
|
|
4135
|
+
// If name.metadata.json is owned by a different UID (e.g. a content record), use -1, -2, etc.
|
|
4136
|
+
let rootMetaPath = await resolveMetaCollision(binDir, rootBasename, rootUid);
|
|
4085
4137
|
|
|
4086
4138
|
// Legacy fallback: rename old-format metadata to new convention
|
|
4087
4139
|
const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
|
|
4088
4140
|
const legacyJsonPath = join(binDir, `${rootBasename}.json`);
|
|
4089
|
-
const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
|
|
4090
4141
|
if (!await fileExists(rootMetaPath)) {
|
|
4091
4142
|
if (await fileExists(legacyTildeOutputMeta)) {
|
|
4092
4143
|
await rename(legacyTildeOutputMeta, rootMetaPath);
|
|
4093
4144
|
log.dim(` Renamed ${basename(legacyTildeOutputMeta)} → ${basename(rootMetaPath)}`);
|
|
4094
|
-
} else if (await fileExists(legacyOutputMeta)) {
|
|
4095
|
-
await rename(legacyOutputMeta, rootMetaPath);
|
|
4096
|
-
log.dim(` Renamed ${basename(legacyOutputMeta)} → ${basename(rootMetaPath)}`);
|
|
4097
4145
|
} else if (await fileExists(legacyJsonPath)) {
|
|
4098
|
-
|
|
4099
|
-
|
|
4146
|
+
// Only rename if the .json file actually looks like output metadata JSON.
|
|
4147
|
+
// Content companions can share the same {name}.json filename pattern —
|
|
4148
|
+
// renaming those would corrupt the content record's companion file.
|
|
4149
|
+
let isOutputMeta = false;
|
|
4150
|
+
try {
|
|
4151
|
+
const legacyContent = await readFile(legacyJsonPath, 'utf8');
|
|
4152
|
+
const legacyParsed = JSON.parse(legacyContent);
|
|
4153
|
+
isOutputMeta = legacyParsed && (legacyParsed._entity === 'output' || legacyParsed.OutputID != null);
|
|
4154
|
+
} catch { /* not valid JSON — definitely a content companion, skip */ }
|
|
4155
|
+
if (isOutputMeta) {
|
|
4156
|
+
await rename(legacyJsonPath, rootMetaPath);
|
|
4157
|
+
log.dim(` Renamed ${rootBasename}.json → ${basename(rootMetaPath)}`);
|
|
4158
|
+
}
|
|
4100
4159
|
}
|
|
4101
4160
|
}
|
|
4102
4161
|
|
|
@@ -4206,28 +4265,34 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
4206
4265
|
/**
|
|
4207
4266
|
* Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to the project root.
|
|
4208
4267
|
*
|
|
4209
|
-
*
|
|
4210
|
-
*
|
|
4211
|
-
*
|
|
4212
|
-
*
|
|
4213
|
-
*
|
|
4214
|
-
*
|
|
4268
|
+
* Two passes:
|
|
4269
|
+
* 1. For each filename in rootContentFiles config: search contentRefs for a matching record,
|
|
4270
|
+
* relocate its companion to the project root, and rewrite the metadata to use
|
|
4271
|
+
* Content: "@/<filename>" (root-relative). Fall back to a stub if no server record.
|
|
4272
|
+
* 2. Promote any remaining content records with BinID=null whose companion filename is also
|
|
4273
|
+
* in rootContentFiles (catches records not matched by pass 1's name/content/path heuristics).
|
|
4215
4274
|
*/
|
|
4216
4275
|
async function writeRootContentFiles(appJson, contentRefs) {
|
|
4217
4276
|
const rootFiles = await loadRootContentFiles();
|
|
4218
|
-
|
|
4277
|
+
const handledUids = new Set();
|
|
4219
4278
|
|
|
4220
4279
|
for (const filename of rootFiles) {
|
|
4221
|
-
const
|
|
4222
|
-
if (
|
|
4280
|
+
const handledUid = await _writeRootFile(filename, appJson, contentRefs);
|
|
4281
|
+
if (handledUid) {
|
|
4282
|
+
handledUids.add(handledUid);
|
|
4283
|
+
} else {
|
|
4223
4284
|
await _generateRootFileStub(filename, appJson);
|
|
4224
4285
|
}
|
|
4225
4286
|
}
|
|
4287
|
+
|
|
4288
|
+
// Promote any content records with no BinID that weren't already handled above,
|
|
4289
|
+
// but only if the companion filename appears in rootContentFiles.
|
|
4290
|
+
await _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles);
|
|
4226
4291
|
}
|
|
4227
4292
|
|
|
4228
4293
|
/**
|
|
4229
4294
|
* Find the content record for a root file and relocate its companion to the project root.
|
|
4230
|
-
* Returns
|
|
4295
|
+
* Returns the record UID if handled, null if no matching record was found.
|
|
4231
4296
|
*/
|
|
4232
4297
|
async function _writeRootFile(filename, appJson, contentRefs) {
|
|
4233
4298
|
const filenameLower = filename.toLowerCase();
|
|
@@ -4283,10 +4348,69 @@ async function _writeRootFile(filename, appJson, contentRefs) {
|
|
|
4283
4348
|
await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
|
|
4284
4349
|
} catch { /* non-critical */ }
|
|
4285
4350
|
|
|
4286
|
-
return
|
|
4351
|
+
return ref.uid;
|
|
4287
4352
|
}
|
|
4288
4353
|
|
|
4289
|
-
return
|
|
4354
|
+
return null; // No server record found
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4357
|
+
/**
|
|
4358
|
+
* Promote content records with no BinID to the project root.
|
|
4359
|
+
* Only promotes records whose companion filename appears in rootContentFiles —
|
|
4360
|
+
* records not in that list stay in their lib/bins/ placement.
|
|
4361
|
+
* Skips records already handled by _writeRootFile (tracked via handledUids).
|
|
4362
|
+
* Companion filename is extracted from the Content @-reference in the metadata.
|
|
4363
|
+
*/
|
|
4364
|
+
async function _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles) {
|
|
4365
|
+
const rootFilesLower = new Set((rootFiles || []).map(f => f.toLowerCase()));
|
|
4366
|
+
for (const ref of contentRefs) {
|
|
4367
|
+
if (handledUids.has(ref.uid)) continue;
|
|
4368
|
+
|
|
4369
|
+
let meta;
|
|
4370
|
+
try { meta = JSON.parse(await readFile(ref.metaPath, 'utf8')); } catch { continue; }
|
|
4371
|
+
|
|
4372
|
+
// Only promote records with no BinID
|
|
4373
|
+
if (meta.BinID != null) continue;
|
|
4374
|
+
|
|
4375
|
+
// Extract companion filename from Content @-reference
|
|
4376
|
+
const metaContent = String(meta.Content || '');
|
|
4377
|
+
const contentRef = metaContent.startsWith('@/') ? metaContent.slice(2)
|
|
4378
|
+
: metaContent.startsWith('@') ? metaContent.slice(1)
|
|
4379
|
+
: null;
|
|
4380
|
+
|
|
4381
|
+
// Skip if no companion reference, if it includes a path (not a root-level file),
|
|
4382
|
+
// or if it's not listed in rootContentFiles.
|
|
4383
|
+
if (!contentRef || contentRef.includes('/')) continue;
|
|
4384
|
+
if (!rootFilesLower.has(contentRef.toLowerCase())) continue;
|
|
4385
|
+
|
|
4386
|
+
const filename = contentRef;
|
|
4387
|
+
const metaDir = dirname(ref.metaPath);
|
|
4388
|
+
const localCompanion = join(metaDir, filename);
|
|
4389
|
+
let content;
|
|
4390
|
+
try {
|
|
4391
|
+
content = await readFile(localCompanion, 'utf8');
|
|
4392
|
+
} catch {
|
|
4393
|
+
try { content = await readFile(join(process.cwd(), filename), 'utf8'); } catch { /* nothing */ }
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
if (content !== undefined) {
|
|
4397
|
+
await writeFile(join(process.cwd(), filename), content);
|
|
4398
|
+
log.dim(` ${filename} written to project root (BinID=null)`);
|
|
4399
|
+
|
|
4400
|
+
if (localCompanion !== join(process.cwd(), filename)) {
|
|
4401
|
+
try { await unlink(localCompanion); } catch { /* already gone or at root */ }
|
|
4402
|
+
}
|
|
4403
|
+
} else {
|
|
4404
|
+
log.warn(` Could not find companion file for ${filename} (BinID=null record)`);
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
// Rewrite metadata: root-relative Content reference
|
|
4408
|
+
try {
|
|
4409
|
+
const updated = { ...meta, Content: `@/${filename}` };
|
|
4410
|
+
if (!updated._companionReferenceColumns) updated._companionReferenceColumns = ['Content'];
|
|
4411
|
+
await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
|
|
4412
|
+
} catch { /* non-critical */ }
|
|
4413
|
+
}
|
|
4290
4414
|
}
|
|
4291
4415
|
|
|
4292
4416
|
/**
|
|
@@ -4307,7 +4431,47 @@ async function _generateRootFileStub(filename, appJson) {
|
|
|
4307
4431
|
}
|
|
4308
4432
|
|
|
4309
4433
|
if (filenameLower === 'claude.md') {
|
|
4310
|
-
const stub =
|
|
4434
|
+
const stub = [
|
|
4435
|
+
`# ${appName}`,
|
|
4436
|
+
``,
|
|
4437
|
+
`## DBO CLI`,
|
|
4438
|
+
``,
|
|
4439
|
+
`Always run \`dbo\` commands from the project root (the directory containing this CLAUDE.md file).`,
|
|
4440
|
+
``,
|
|
4441
|
+
`## DBO API Submissions`,
|
|
4442
|
+
``,
|
|
4443
|
+
`- To create new records, use the REST API (\`/api/input/submit\`) directly — the CLI has no \`add\` command.`,
|
|
4444
|
+
`- This project may require a ticket ID for all submissions when the RepositoryIntegrationID column in the baseline JSON is set. Read it from \`.app/ticketing.local.json\` and include \`_OverrideTicketID={ticket_id}\` as a query parameter on every \`/api/input/submit\` call.`,
|
|
4445
|
+
`- Cookie file for auth: \`.app/cookies.txt\``,
|
|
4446
|
+
`- If the project's baseline JSON (\`.app/baseline.json\`) has a \`ModifyKey\` field, the app is not updatable and should not be edited. If you must, the user must be supplying that key as _modifyKey in the submission query parameters to update existing records.`,
|
|
4447
|
+
`- Domain: read from \`.app/config.json\` (\`domain\` field)`,
|
|
4448
|
+
``,
|
|
4449
|
+
`## Documentation`,
|
|
4450
|
+
``,
|
|
4451
|
+
`Project docs may live in any of these locations:`,
|
|
4452
|
+
``,
|
|
4453
|
+
`- \`CLAUDE.md\` _(this file)_`,
|
|
4454
|
+
`- \`README.md\``,
|
|
4455
|
+
`- \`docs/\``,
|
|
4456
|
+
`- \`lib/bins/docs/\``,
|
|
4457
|
+
`- \`lib/extension/Documentation/\``,
|
|
4458
|
+
``,
|
|
4459
|
+
`For dependency apps, look under \`app_dependencies/<app_short_name>/\` using the same structure:`,
|
|
4460
|
+
``,
|
|
4461
|
+
`- \`app_dependencies/<app_short_name>/CLAUDE.md\``,
|
|
4462
|
+
`- \`app_dependencies/<app_short_name>/README.md\``,
|
|
4463
|
+
`- \`app_dependencies/<app_short_name>/docs/\``,
|
|
4464
|
+
`- \`app_dependencies/<app_short_name>/lib/bins/docs/\``,
|
|
4465
|
+
`- \`app_dependencies/<app_short_name>/lib/extension/Documentation/\``,
|
|
4466
|
+
``,
|
|
4467
|
+
`### DBO.io Framework API Reference`,
|
|
4468
|
+
``,
|
|
4469
|
+
`For the DBO.io REST API (input/submit, output, content, etc.), check in priority order:`,
|
|
4470
|
+
``,
|
|
4471
|
+
`1. \`app_dependencies/_system/lib/bins/docs/\` — authoritative source`,
|
|
4472
|
+
`2. \`plugins/claude/dbo/skills/white-paper/references/\` — fallback reference`,
|
|
4473
|
+
``,
|
|
4474
|
+
].join('\n');
|
|
4311
4475
|
await writeFile(rootPath, stub);
|
|
4312
4476
|
log.dim(` CLAUDE.md generated at project root (stub)`);
|
|
4313
4477
|
return;
|
|
@@ -4322,6 +4486,32 @@ async function _generateRootFileStub(filename, appJson) {
|
|
|
4322
4486
|
return;
|
|
4323
4487
|
}
|
|
4324
4488
|
|
|
4489
|
+
if (filenameLower === 'package.json') {
|
|
4490
|
+
const shortName = (appJson.ShortName || appName || 'app').toLowerCase().replace(/\s+/g, '-');
|
|
4491
|
+
const stub = JSON.stringify({
|
|
4492
|
+
name: shortName,
|
|
4493
|
+
version: '1.0.0',
|
|
4494
|
+
description: description || '',
|
|
4495
|
+
private: true,
|
|
4496
|
+
}, null, 2) + '\n';
|
|
4497
|
+
await writeFile(rootPath, stub);
|
|
4498
|
+
log.dim(` package.json generated at project root (stub)`);
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
if (filenameLower === '.dboignore') {
|
|
4503
|
+
await writeFile(rootPath, getDboignoreDefaultContent());
|
|
4504
|
+
log.dim(` .dboignore generated at project root (stub)`);
|
|
4505
|
+
return;
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
if (filenameLower === '.gitignore') {
|
|
4509
|
+
const lines = DEFAULT_GITIGNORE_ENTRIES.map(e => e).join('\n') + '\n';
|
|
4510
|
+
await writeFile(rootPath, lines);
|
|
4511
|
+
log.dim(` .gitignore generated at project root (stub)`);
|
|
4512
|
+
return;
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4325
4515
|
// Unknown file type — no stub generated
|
|
4326
4516
|
}
|
|
4327
4517
|
|
package/src/commands/init.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { mkdir, writeFile, access } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
|
|
4
|
+
import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, DEFAULT_GITIGNORE_ENTRIES, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
|
|
5
5
|
import { installOrUpdateClaudeCommands } from './install.js';
|
|
6
6
|
import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
|
|
7
7
|
import { createDboignore } from '../lib/ignore.js';
|
|
@@ -108,7 +108,7 @@ export const initCommand = new Command('init')
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
// Ensure sensitive files are gitignored
|
|
111
|
-
await ensureGitignore(
|
|
111
|
+
await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
|
|
112
112
|
|
|
113
113
|
const createdIgnore = await createDboignore();
|
|
114
114
|
if (createdIgnore) log.dim(' Created .dboignore');
|
package/src/commands/input.js
CHANGED
|
@@ -15,7 +15,7 @@ export const inputCommand = new Command('input')
|
|
|
15
15
|
.description('Submit CRUD operations to DBO.io (add, edit, delete records)')
|
|
16
16
|
.requiredOption('-d, --data <expr>', 'DBO input expression (repeatable)', collect, [])
|
|
17
17
|
.option('-f, --file <field=@path>', 'File attachment for multipart upload (repeatable)', collect, [])
|
|
18
|
-
.option('-C, --confirm
|
|
18
|
+
.option('-C, --confirm [value]', 'Commit changes: true (default) or false for validation only', 'true')
|
|
19
19
|
.option('--ticket <id>', 'Override ticket ID (_OverrideTicketID)')
|
|
20
20
|
.option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
|
|
21
21
|
.option('--row-key <type>', 'Row key type (RowUID or RowID) — no-op for -d passthrough, available for consistency')
|
|
@@ -32,7 +32,7 @@ export const inputCommand = new Command('input')
|
|
|
32
32
|
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
33
33
|
|
|
34
34
|
const extraParams = {};
|
|
35
|
-
extraParams['_confirm'] = options.confirm;
|
|
35
|
+
extraParams['_confirm'] = String(options.confirm);
|
|
36
36
|
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
37
37
|
if (options.login) extraParams['_login'] = 'true';
|
|
38
38
|
if (options.transactional) extraParams['_transactional'] = 'true';
|
package/src/commands/login.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { loadConfig, initConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../lib/config.js';
|
|
2
|
+
import { loadConfig, initConfig, saveUserInfo, saveUserProfile, ensureGitignore, DEFAULT_GITIGNORE_ENTRIES } from '../lib/config.js';
|
|
3
3
|
import { DboClient } from '../lib/client.js';
|
|
4
4
|
import { log } from '../lib/logger.js';
|
|
5
5
|
|
|
@@ -49,7 +49,7 @@ export async function performLogin(domain, knownUsername) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
|
|
52
|
-
await ensureGitignore(
|
|
52
|
+
await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
|
|
53
53
|
|
|
54
54
|
// Fetch and store user info (non-critical)
|
|
55
55
|
try {
|
|
@@ -143,7 +143,7 @@ export const loginCommand = new Command('login')
|
|
|
143
143
|
log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
|
|
144
144
|
|
|
145
145
|
// Ensure sensitive files are gitignored
|
|
146
|
-
await ensureGitignore(
|
|
146
|
+
await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
|
|
147
147
|
|
|
148
148
|
// Fetch current user info to store ID for future submissions
|
|
149
149
|
try {
|