@dboio/cli 0.19.4 → 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 +69 -16
- package/bin/dbo.js +5 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +6 -1
- package/plugins/claude/dbo/commands/dbo.md +39 -12
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +69 -16
- package/plugins/claude/dbo/skills/cookbook/SKILL.md +162 -0
- 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 +69 -14
- package/src/commands/clone.js +451 -87
- 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 +203 -54
- package/src/commands/status.js +15 -7
- package/src/lib/config.js +137 -10
- 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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes } from 'fs/promises';
|
|
2
|
+
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes, unlink } from 'fs/promises';
|
|
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, 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 } 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,14 +383,33 @@ 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
|
}
|
|
362
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Classify a media record based on its FullPath relative to the app short name.
|
|
395
|
+
*
|
|
396
|
+
* Returns:
|
|
397
|
+
* 'app' — /media/<appShortName>/app/... or no FullPath (assumed app media)
|
|
398
|
+
* 'user' — /media/<appShortName>/user/...
|
|
399
|
+
* 'foreign' — /media/<other_app>/... (belongs to a different app entirely)
|
|
400
|
+
*/
|
|
401
|
+
function classifyMediaRecord(record, appShortName) {
|
|
402
|
+
const fp = record.FullPath;
|
|
403
|
+
if (!fp || !appShortName) return 'app';
|
|
404
|
+
const normalized = fp.startsWith('/') ? fp.slice(1) : fp;
|
|
405
|
+
// Must start with media/<appShortName>/
|
|
406
|
+
const appPrefix = `media/${appShortName.toLowerCase()}/`;
|
|
407
|
+
if (!normalized.toLowerCase().startsWith(appPrefix)) return 'foreign';
|
|
408
|
+
const rest = normalized.slice(appPrefix.length);
|
|
409
|
+
if (rest.toLowerCase().startsWith('user/')) return 'user';
|
|
410
|
+
return 'app';
|
|
411
|
+
}
|
|
412
|
+
|
|
363
413
|
/**
|
|
364
414
|
* Extract path components for entity-dir records.
|
|
365
415
|
* Simplified from processEntityDirEntries() for collision detection.
|
|
@@ -377,8 +427,7 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
|
377
427
|
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
378
428
|
}
|
|
379
429
|
|
|
380
|
-
const
|
|
381
|
-
const metaPath = join(dirName, buildMetaFilename(name, uid));
|
|
430
|
+
const metaPath = join(dirName, buildMetaFilename(name));
|
|
382
431
|
return { dir: dirName, name, metaPath };
|
|
383
432
|
}
|
|
384
433
|
|
|
@@ -1177,11 +1226,25 @@ export async function performClone(source, options = {}) {
|
|
|
1177
1226
|
}
|
|
1178
1227
|
}
|
|
1179
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
|
+
|
|
1180
1238
|
// Regenerate metadata_schema.json for any new entity types
|
|
1181
1239
|
if (schema) {
|
|
1182
1240
|
const existing = await loadMetadataSchema();
|
|
1183
1241
|
const updated = generateMetadataFromSchema(schema, existing ?? {});
|
|
1184
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
|
+
}
|
|
1185
1248
|
}
|
|
1186
1249
|
|
|
1187
1250
|
// Domain change detection
|
|
@@ -1202,7 +1265,7 @@ export async function performClone(source, options = {}) {
|
|
|
1202
1265
|
|
|
1203
1266
|
// Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
|
|
1204
1267
|
if (!isDependencyCheckout()) {
|
|
1205
|
-
await ensureGitignore(
|
|
1268
|
+
await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
|
|
1206
1269
|
}
|
|
1207
1270
|
|
|
1208
1271
|
// Step 2: Update .app/config.json (skip in pull mode — config already set)
|
|
@@ -1393,9 +1456,10 @@ export async function performClone(source, options = {}) {
|
|
|
1393
1456
|
);
|
|
1394
1457
|
}
|
|
1395
1458
|
|
|
1396
|
-
// Step 5a: Write manifest.json to project root
|
|
1397
|
-
|
|
1398
|
-
|
|
1459
|
+
// Step 5a: Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to project root.
|
|
1460
|
+
// Also fixes the duplicate bug: relocates companions from lib/bins/app/ to root and rewrites metadata.
|
|
1461
|
+
if (!entityFilter || entityFilter.has('content')) {
|
|
1462
|
+
await writeRootContentFiles(appJson, contentRefs);
|
|
1399
1463
|
}
|
|
1400
1464
|
|
|
1401
1465
|
// Step 5b: Process media → download binary files + metadata (skip rejected records)
|
|
@@ -1957,14 +2021,15 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1957
2021
|
}
|
|
1958
2022
|
|
|
1959
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.
|
|
1960
2025
|
const uid = record.UID || 'untitled';
|
|
1961
|
-
const nameKey = name;
|
|
2026
|
+
const nameKey = name.toLowerCase();
|
|
1962
2027
|
const count = usedNames.get(nameKey) || 0;
|
|
1963
2028
|
usedNames.set(nameKey, count + 1);
|
|
1964
2029
|
if (count > 0) name = `${name}-${count}`;
|
|
1965
2030
|
|
|
1966
|
-
// Metadata: name.metadata
|
|
1967
|
-
const metaPath = join(dirName, buildMetaFilename(name
|
|
2031
|
+
// Metadata: name.metadata.json; companion files use natural name
|
|
2032
|
+
const metaPath = join(dirName, buildMetaFilename(name));
|
|
1968
2033
|
|
|
1969
2034
|
// Legacy detection: rename old-format metadata files to new convention
|
|
1970
2035
|
const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
|
|
@@ -2196,7 +2261,7 @@ function parseFormControlCode(string5) {
|
|
|
2196
2261
|
if (codeStr) {
|
|
2197
2262
|
for (const pair of codeStr.split(',')) {
|
|
2198
2263
|
const [col, ext] = pair.split('|');
|
|
2199
|
-
if (col?.trim()
|
|
2264
|
+
if (col?.trim()) colToExt.set(col.trim(), ext?.trim().toLowerCase() || 'md');
|
|
2200
2265
|
}
|
|
2201
2266
|
}
|
|
2202
2267
|
const titleStr = params.get('form-control-title');
|
|
@@ -2263,9 +2328,10 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
|
|
|
2263
2328
|
const { colToExt, colToTitle } = parseFormControlCode(string5);
|
|
2264
2329
|
if (colToExt.size === 0) continue;
|
|
2265
2330
|
|
|
2266
|
-
// Only
|
|
2331
|
+
// Only skip if the existing entry already has @reference expressions;
|
|
2332
|
+
// plain-column entries (seeded without form-control-code) should be overwritten
|
|
2267
2333
|
const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
|
|
2268
|
-
if (existing) continue;
|
|
2334
|
+
if (existing?.some(e => e.includes('@reference'))) continue;
|
|
2269
2335
|
|
|
2270
2336
|
const refEntries = [];
|
|
2271
2337
|
for (const [col, ext] of colToExt) {
|
|
@@ -2448,8 +2514,6 @@ function guessExtensionForDescriptor(descriptor, columnName) {
|
|
|
2448
2514
|
* @returns {Promise<'inline'|'root'>}
|
|
2449
2515
|
*/
|
|
2450
2516
|
async function resolveDocumentationPlacement(options) {
|
|
2451
|
-
if (options.yes) return 'inline';
|
|
2452
|
-
|
|
2453
2517
|
const saved = await loadExtensionDocumentationMDPlacement();
|
|
2454
2518
|
if (saved && !options.force && !options.configure) {
|
|
2455
2519
|
log.dim(` Documentation MD placement: ${saved} (saved)`);
|
|
@@ -2457,7 +2521,7 @@ async function resolveDocumentationPlacement(options) {
|
|
|
2457
2521
|
}
|
|
2458
2522
|
|
|
2459
2523
|
let placement;
|
|
2460
|
-
if (options.configure) {
|
|
2524
|
+
if (options.configure && !options.yes) {
|
|
2461
2525
|
const inquirer = (await import('inquirer')).default;
|
|
2462
2526
|
({ placement } = await inquirer.prompt([{
|
|
2463
2527
|
type: 'list', name: 'placement',
|
|
@@ -2499,7 +2563,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2499
2563
|
log.info(`Processing ${entries.length} extension record(s)...`);
|
|
2500
2564
|
|
|
2501
2565
|
// Step A: Pre-pass — build mapping + create directories
|
|
2502
|
-
const metadataSchema = await loadMetadataSchema();
|
|
2566
|
+
const metadataSchema = (await loadMetadataSchema()) ?? {};
|
|
2503
2567
|
const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
|
|
2504
2568
|
|
|
2505
2569
|
// Clear documentation preferences when --force is used with --documentation-only
|
|
@@ -2547,12 +2611,19 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2547
2611
|
}
|
|
2548
2612
|
|
|
2549
2613
|
// Step D: Write files, one group at a time
|
|
2614
|
+
// descriptor_definition must be written before dependent descriptors (e.g. control)
|
|
2550
2615
|
const refs = [];
|
|
2551
2616
|
const bulkAction = { value: null };
|
|
2552
2617
|
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
2553
2618
|
const config = await loadConfig();
|
|
2554
2619
|
|
|
2555
|
-
|
|
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) {
|
|
2556
2627
|
const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
|
|
2557
2628
|
const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
|
|
2558
2629
|
const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
|
|
@@ -2572,14 +2643,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2572
2643
|
}
|
|
2573
2644
|
|
|
2574
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.
|
|
2575
2647
|
const uid = record.UID || 'untitled';
|
|
2576
|
-
const nameKey = name;
|
|
2648
|
+
const nameKey = name.toLowerCase();
|
|
2577
2649
|
const nameCount = usedNames.get(nameKey) || 0;
|
|
2578
2650
|
usedNames.set(nameKey, nameCount + 1);
|
|
2579
2651
|
if (nameCount > 0) name = `${name}-${nameCount}`;
|
|
2580
2652
|
|
|
2581
|
-
// Metadata: name.metadata
|
|
2582
|
-
const metaPath = join(dir, buildMetaFilename(name
|
|
2653
|
+
// Metadata: name.metadata.json; companion files use natural name
|
|
2654
|
+
const metaPath = join(dir, buildMetaFilename(name));
|
|
2583
2655
|
|
|
2584
2656
|
// Legacy detection: rename old-format metadata files to new convention
|
|
2585
2657
|
const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
|
|
@@ -2775,22 +2847,92 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2775
2847
|
async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set(), resolvedFilenames = new Map()) {
|
|
2776
2848
|
if (!mediaRecords || mediaRecords.length === 0) return [];
|
|
2777
2849
|
|
|
2850
|
+
// ── Step 0: Classify records by path scope ──────────────────────────────
|
|
2851
|
+
// foreign = /media/<other_app>/... (not this app — never download)
|
|
2852
|
+
// user = /media/<appShortName>/user/... (opt-in via UserMedia config)
|
|
2853
|
+
// app = /media/<appShortName>/app/... or no FullPath (always download)
|
|
2854
|
+
const foreignRecords = [];
|
|
2855
|
+
const userRecords = [];
|
|
2856
|
+
const appRecords = [];
|
|
2857
|
+
|
|
2858
|
+
for (const record of mediaRecords) {
|
|
2859
|
+
if (skipUIDs.has(record.UID)) continue;
|
|
2860
|
+
const cls = classifyMediaRecord(record, appShortName);
|
|
2861
|
+
if (cls === 'foreign') foreignRecords.push(record);
|
|
2862
|
+
else if (cls === 'user') userRecords.push(record);
|
|
2863
|
+
else appRecords.push(record);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// Silently write stale metadata for foreign records so they are never
|
|
2867
|
+
// re-prompted on future clones. No download is attempted.
|
|
2868
|
+
const foreignRefs = [];
|
|
2869
|
+
for (const record of foreignRecords) {
|
|
2870
|
+
const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
|
|
2871
|
+
const resolvedName = resolvedFilenames.get(record.UID);
|
|
2872
|
+
const effectiveMetaPath = resolvedName
|
|
2873
|
+
? join(scanDir, buildMetaFilename(resolvedName))
|
|
2874
|
+
: scanMetaPath;
|
|
2875
|
+
if (!(await fileExists(effectiveMetaPath))) {
|
|
2876
|
+
const staleMeta = { _entity: 'media', _foreignApp: true };
|
|
2877
|
+
for (const [k, v] of Object.entries(record)) {
|
|
2878
|
+
if (k !== 'children') staleMeta[k] = v;
|
|
2879
|
+
}
|
|
2880
|
+
try {
|
|
2881
|
+
await mkdir(scanDir, { recursive: true });
|
|
2882
|
+
await writeFile(effectiveMetaPath, JSON.stringify(staleMeta, null, 2) + '\n');
|
|
2883
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
2884
|
+
await setFileTimestamps(effectiveMetaPath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
2885
|
+
}
|
|
2886
|
+
} catch { /* non-critical */ }
|
|
2887
|
+
}
|
|
2888
|
+
foreignRefs.push({ uid: record.UID, metaPath: effectiveMetaPath });
|
|
2889
|
+
}
|
|
2890
|
+
if (foreignRecords.length > 0) {
|
|
2891
|
+
log.dim(` Skipped ${foreignRecords.length} foreign media file(s) (not in /media/${appShortName || '?'}/)`);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
// ── User media: prompt once if preference is unset ───────────────────────
|
|
2895
|
+
// config.UserMedia: true = include, false = skip, undefined = not yet asked
|
|
2896
|
+
let includeUserMedia = config.UserMedia ?? null;
|
|
2897
|
+
if (userRecords.length > 0 && includeUserMedia === null && !options.yes) {
|
|
2898
|
+
const inquirer = (await import('inquirer')).default;
|
|
2899
|
+
const exampleFile = userRecords[0].Filename || userRecords[0].Name || 'user file';
|
|
2900
|
+
const { includeUser } = await inquirer.prompt([{
|
|
2901
|
+
type: 'confirm',
|
|
2902
|
+
name: 'includeUser',
|
|
2903
|
+
message: `App has ${userRecords.length} user media file(s) (e.g. "${exampleFile}"). Download user media? (saves preference)`,
|
|
2904
|
+
default: false,
|
|
2905
|
+
}]);
|
|
2906
|
+
includeUserMedia = includeUser;
|
|
2907
|
+
await updateConfigUserMedia(includeUserMedia);
|
|
2908
|
+
} else if (userRecords.length > 0 && options.yes && includeUserMedia === null) {
|
|
2909
|
+
// Non-interactive: default to false, save preference
|
|
2910
|
+
includeUserMedia = false;
|
|
2911
|
+
await updateConfigUserMedia(false);
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
if (!includeUserMedia && userRecords.length > 0) {
|
|
2915
|
+
log.dim(` Skipped ${userRecords.length} user media file(s) (UserMedia=false)`);
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// Active records = app media + user media (if opted in)
|
|
2919
|
+
const activeRecords = includeUserMedia ? [...appRecords, ...userRecords] : appRecords;
|
|
2920
|
+
|
|
2778
2921
|
// Track stale records (404s) for cleanup prompt
|
|
2779
2922
|
const staleRecords = [];
|
|
2780
2923
|
|
|
2781
2924
|
// Pre-scan: determine which media files actually need downloading
|
|
2782
2925
|
// (new files or files with newer server timestamps)
|
|
2783
2926
|
const needsDownload = [];
|
|
2784
|
-
const upToDateRefs = [];
|
|
2927
|
+
const upToDateRefs = [...foreignRefs];
|
|
2785
2928
|
|
|
2786
|
-
for (const record of
|
|
2787
|
-
if (skipUIDs.has(record.UID)) continue;
|
|
2929
|
+
for (const record of activeRecords) {
|
|
2788
2930
|
|
|
2789
2931
|
const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
|
|
2790
2932
|
// Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
|
|
2791
2933
|
const resolvedName = resolvedFilenames.get(record.UID);
|
|
2792
2934
|
const effectiveMetaPath = resolvedName
|
|
2793
|
-
? join(scanDir, buildMetaFilename(resolvedName
|
|
2935
|
+
? join(scanDir, buildMetaFilename(resolvedName))
|
|
2794
2936
|
: scanMetaPath;
|
|
2795
2937
|
const scanExists = await fileExists(effectiveMetaPath);
|
|
2796
2938
|
|
|
@@ -2815,7 +2957,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2815
2957
|
}
|
|
2816
2958
|
|
|
2817
2959
|
if (needsDownload.length === 0) {
|
|
2818
|
-
log.dim(` All ${
|
|
2960
|
+
log.dim(` All ${activeRecords.length} media file(s) up to date`);
|
|
2819
2961
|
return upToDateRefs;
|
|
2820
2962
|
}
|
|
2821
2963
|
|
|
@@ -2828,7 +2970,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2828
2970
|
const { download } = await inquirer.prompt([{
|
|
2829
2971
|
type: 'confirm',
|
|
2830
2972
|
name: 'download',
|
|
2831
|
-
message: `${needsDownload.length} media file(s) need to be downloaded (${
|
|
2973
|
+
message: `${needsDownload.length} media file(s) need to be downloaded (${activeRecords.length - needsDownload.length} up to date). Attempt download now?`,
|
|
2832
2974
|
default: true,
|
|
2833
2975
|
}]);
|
|
2834
2976
|
canDownload = download;
|
|
@@ -2857,9 +2999,9 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2857
2999
|
let downloaded = 0;
|
|
2858
3000
|
let failed = 0;
|
|
2859
3001
|
|
|
2860
|
-
log.info(`Downloading ${
|
|
3002
|
+
log.info(`Downloading ${activeRecords.length} media file(s)...`);
|
|
2861
3003
|
|
|
2862
|
-
for (const record of
|
|
3004
|
+
for (const record of activeRecords) {
|
|
2863
3005
|
const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
|
|
2864
3006
|
|
|
2865
3007
|
if (skipUIDs.has(record.UID)) {
|
|
@@ -2880,12 +3022,11 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2880
3022
|
if (!dir) dir = BINS_DIR;
|
|
2881
3023
|
await mkdir(dir, { recursive: true });
|
|
2882
3024
|
|
|
2883
|
-
// Companion: natural name
|
|
2884
|
-
const uid = String(record.UID || record._id || 'untitled');
|
|
3025
|
+
// Companion: natural name (use collision-resolved override if available)
|
|
2885
3026
|
const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
|
|
2886
3027
|
const filePath = join(dir, finalFilename);
|
|
2887
|
-
// Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata
|
|
2888
|
-
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));
|
|
2889
3030
|
// usedNames retained for tracking
|
|
2890
3031
|
const fileKey = `${dir}/${name}.${ext}`;
|
|
2891
3032
|
usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
|
|
@@ -3157,7 +3298,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3157
3298
|
probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
|
|
3158
3299
|
if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
|
|
3159
3300
|
|
|
3160
|
-
const probeMeta = join(probeDir, buildMetaFilename(sanitized
|
|
3301
|
+
const probeMeta = join(probeDir, buildMetaFilename(sanitized));
|
|
3161
3302
|
const raw = await readFile(probeMeta, 'utf8');
|
|
3162
3303
|
const localMeta = JSON.parse(raw);
|
|
3163
3304
|
// Extract extension from Content @reference (e.g. "@Name~uid.html")
|
|
@@ -3248,8 +3389,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3248
3389
|
const uid = String(record.UID || record._id || 'untitled');
|
|
3249
3390
|
// Companion: natural name, no UID (use collision-resolved override if available)
|
|
3250
3391
|
const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
|
|
3251
|
-
// Metadata: name.metadata
|
|
3252
|
-
// usedNames retained for non-UID edge case tracking
|
|
3392
|
+
// Metadata: name.metadata.json; usedNames retained for non-UID edge case tracking
|
|
3253
3393
|
const nameKey = `${dir}/${name}`;
|
|
3254
3394
|
usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
|
|
3255
3395
|
|
|
@@ -3261,7 +3401,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3261
3401
|
);
|
|
3262
3402
|
|
|
3263
3403
|
const filePath = join(dir, fileName);
|
|
3264
|
-
const metaPath = join(dir, buildMetaFilename(name
|
|
3404
|
+
const metaPath = join(dir, buildMetaFilename(name));
|
|
3265
3405
|
|
|
3266
3406
|
// Rename legacy ~UID companion files to natural names if needed
|
|
3267
3407
|
if (await fileExists(metaPath)) {
|
|
@@ -3990,22 +4130,32 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3990
4130
|
// Build root output filename (natural name, no UID in stem)
|
|
3991
4131
|
const rootBasename = buildOutputFilename('output', output, filenameCols.output);
|
|
3992
4132
|
const rootUid = output.UID || '';
|
|
3993
|
-
|
|
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);
|
|
3994
4137
|
|
|
3995
4138
|
// Legacy fallback: rename old-format metadata to new convention
|
|
3996
4139
|
const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
|
|
3997
4140
|
const legacyJsonPath = join(binDir, `${rootBasename}.json`);
|
|
3998
|
-
const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
|
|
3999
4141
|
if (!await fileExists(rootMetaPath)) {
|
|
4000
4142
|
if (await fileExists(legacyTildeOutputMeta)) {
|
|
4001
4143
|
await rename(legacyTildeOutputMeta, rootMetaPath);
|
|
4002
4144
|
log.dim(` Renamed ${basename(legacyTildeOutputMeta)} → ${basename(rootMetaPath)}`);
|
|
4003
|
-
} else if (await fileExists(legacyOutputMeta)) {
|
|
4004
|
-
await rename(legacyOutputMeta, rootMetaPath);
|
|
4005
|
-
log.dim(` Renamed ${basename(legacyOutputMeta)} → ${basename(rootMetaPath)}`);
|
|
4006
4145
|
} else if (await fileExists(legacyJsonPath)) {
|
|
4007
|
-
|
|
4008
|
-
|
|
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
|
+
}
|
|
4009
4159
|
}
|
|
4010
4160
|
}
|
|
4011
4161
|
|
|
@@ -4113,53 +4263,267 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
4113
4263
|
}
|
|
4114
4264
|
|
|
4115
4265
|
/**
|
|
4116
|
-
* Write manifest.json to project root.
|
|
4117
|
-
*
|
|
4118
|
-
*
|
|
4266
|
+
* Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to the project root.
|
|
4267
|
+
*
|
|
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).
|
|
4119
4274
|
*/
|
|
4120
|
-
async function
|
|
4121
|
-
|
|
4275
|
+
async function writeRootContentFiles(appJson, contentRefs) {
|
|
4276
|
+
const rootFiles = await loadRootContentFiles();
|
|
4277
|
+
const handledUids = new Set();
|
|
4278
|
+
|
|
4279
|
+
for (const filename of rootFiles) {
|
|
4280
|
+
const handledUid = await _writeRootFile(filename, appJson, contentRefs);
|
|
4281
|
+
if (handledUid) {
|
|
4282
|
+
handledUids.add(handledUid);
|
|
4283
|
+
} else {
|
|
4284
|
+
await _generateRootFileStub(filename, appJson);
|
|
4285
|
+
}
|
|
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);
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
/**
|
|
4294
|
+
* Find the content record for a root file and relocate its companion to the project root.
|
|
4295
|
+
* Returns the record UID if handled, null if no matching record was found.
|
|
4296
|
+
*/
|
|
4297
|
+
async function _writeRootFile(filename, appJson, contentRefs) {
|
|
4298
|
+
const filenameLower = filename.toLowerCase();
|
|
4299
|
+
const stemLower = filenameLower.includes('.') ? filenameLower.slice(0, filenameLower.lastIndexOf('.')) : filenameLower;
|
|
4300
|
+
const extLower = filenameLower.includes('.') ? filenameLower.slice(filenameLower.lastIndexOf('.') + 1) : '';
|
|
4301
|
+
|
|
4122
4302
|
for (const ref of contentRefs) {
|
|
4123
4303
|
let meta;
|
|
4304
|
+
try { meta = JSON.parse(await readFile(ref.metaPath, 'utf8')); } catch { continue; }
|
|
4305
|
+
|
|
4306
|
+
// Match by Name+Extension, or by Content @reference, or by Path
|
|
4307
|
+
const metaName = (meta.Name || '').toLowerCase();
|
|
4308
|
+
const metaExt = (meta.Extension || '').toLowerCase();
|
|
4309
|
+
const metaContent = String(meta.Content || '');
|
|
4310
|
+
const metaPath2 = String(meta.Path || '');
|
|
4311
|
+
const contentRef = metaContent.startsWith('@/') ? metaContent.slice(2)
|
|
4312
|
+
: metaContent.startsWith('@') ? metaContent.slice(1)
|
|
4313
|
+
: null;
|
|
4314
|
+
|
|
4315
|
+
const matchByName = metaName.startsWith(stemLower) && metaExt === extLower;
|
|
4316
|
+
const matchByContent = contentRef && contentRef.toLowerCase() === filenameLower;
|
|
4317
|
+
const matchByPath = metaPath2.replace(/^\//, '').toLowerCase() === filenameLower;
|
|
4318
|
+
|
|
4319
|
+
if (!matchByName && !matchByContent && !matchByPath) continue;
|
|
4320
|
+
|
|
4321
|
+
// Found a matching record — read companion content from bins/app dir
|
|
4322
|
+
const metaDir = dirname(ref.metaPath);
|
|
4323
|
+
const localCompanion = join(metaDir, filename);
|
|
4324
|
+
let content;
|
|
4124
4325
|
try {
|
|
4125
|
-
|
|
4126
|
-
} catch {
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
: [join(dirname(ref.metaPath), refFile), join(process.cwd(), refFile)];
|
|
4139
|
-
let content;
|
|
4140
|
-
for (const candidate of candidates) {
|
|
4141
|
-
try {
|
|
4142
|
-
content = await readFile(candidate, 'utf8');
|
|
4143
|
-
break;
|
|
4144
|
-
} catch { /* try next */ }
|
|
4145
|
-
}
|
|
4146
|
-
if (content !== undefined) {
|
|
4147
|
-
await writeFile('manifest.json', content);
|
|
4148
|
-
log.dim(' manifest.json written to project root (from server content)');
|
|
4149
|
-
} else {
|
|
4150
|
-
log.warn(` Could not find manifest.json companion file`);
|
|
4151
|
-
}
|
|
4326
|
+
content = await readFile(localCompanion, 'utf8');
|
|
4327
|
+
} catch {
|
|
4328
|
+
// Companion may already be at root (re-clone scenario) or never written (no Content on server)
|
|
4329
|
+
try { content = await readFile(join(process.cwd(), filename), 'utf8'); } catch { /* nothing */ }
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4332
|
+
if (content !== undefined) {
|
|
4333
|
+
await writeFile(join(process.cwd(), filename), content);
|
|
4334
|
+
log.dim(` ${filename} written to project root (from server content)`);
|
|
4335
|
+
|
|
4336
|
+
// Delete stale companion in bins/app if it exists there
|
|
4337
|
+
if (localCompanion !== join(process.cwd(), filename)) {
|
|
4338
|
+
try { await unlink(localCompanion); } catch { /* already gone or at root */ }
|
|
4152
4339
|
}
|
|
4153
|
-
|
|
4340
|
+
} else {
|
|
4341
|
+
log.warn(` Could not find companion file for ${filename}`);
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
// Rewrite metadata: root-relative Content reference
|
|
4345
|
+
try {
|
|
4346
|
+
const updated = { ...meta, Content: `@/${filename}` };
|
|
4347
|
+
if (!updated._companionReferenceColumns) updated._companionReferenceColumns = ['Content'];
|
|
4348
|
+
await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
|
|
4349
|
+
} catch { /* non-critical */ }
|
|
4350
|
+
|
|
4351
|
+
return ref.uid;
|
|
4352
|
+
}
|
|
4353
|
+
|
|
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)`);
|
|
4154
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
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
/**
|
|
4417
|
+
* Generate a fallback stub for a root content file when the server has no record.
|
|
4418
|
+
* Skips if the file already exists at the project root.
|
|
4419
|
+
*/
|
|
4420
|
+
async function _generateRootFileStub(filename, appJson) {
|
|
4421
|
+
const rootPath = join(process.cwd(), filename);
|
|
4422
|
+
try { await access(rootPath); return; } catch { /* doesn't exist — generate */ }
|
|
4423
|
+
|
|
4424
|
+
const appName = appJson.Name || appJson.ShortName || '';
|
|
4425
|
+
const description = appJson.Description || '';
|
|
4426
|
+
const filenameLower = filename.toLowerCase();
|
|
4427
|
+
|
|
4428
|
+
if (filenameLower === 'manifest.json') {
|
|
4429
|
+
await _generateManifestFallback(appJson);
|
|
4430
|
+
return;
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
if (filenameLower === 'claude.md') {
|
|
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');
|
|
4475
|
+
await writeFile(rootPath, stub);
|
|
4476
|
+
log.dim(` CLAUDE.md generated at project root (stub)`);
|
|
4477
|
+
return;
|
|
4155
4478
|
}
|
|
4156
4479
|
|
|
4157
|
-
|
|
4480
|
+
if (filenameLower === 'readme.md') {
|
|
4481
|
+
const parts = [`# ${appName}`];
|
|
4482
|
+
if (description) parts.push('', description);
|
|
4483
|
+
parts.push('');
|
|
4484
|
+
await writeFile(rootPath, parts.join('\n'));
|
|
4485
|
+
log.dim(` README.md generated at project root (stub)`);
|
|
4486
|
+
return;
|
|
4487
|
+
}
|
|
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
|
+
|
|
4515
|
+
// Unknown file type — no stub generated
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
/**
|
|
4519
|
+
* Generate manifest.json at project root from appJson values.
|
|
4520
|
+
* Preserves existing behaviour from the old writeManifestJson fallback.
|
|
4521
|
+
*/
|
|
4522
|
+
async function _generateManifestFallback(appJson) {
|
|
4158
4523
|
const shortName = appJson.ShortName || '';
|
|
4159
4524
|
const appName = appJson.Name || '';
|
|
4160
4525
|
const description = appJson.Description || '';
|
|
4161
4526
|
|
|
4162
|
-
// Find background_color from extension children (widget descriptor matching ShortName)
|
|
4163
4527
|
let bgColor = '#ffffff';
|
|
4164
4528
|
if (shortName) {
|
|
4165
4529
|
const extensions = appJson.children?.extension || [];
|
|
@@ -4191,7 +4555,7 @@ async function writeManifestJson(appJson, contentRefs) {
|
|
|
4191
4555
|
icons: [],
|
|
4192
4556
|
};
|
|
4193
4557
|
|
|
4194
|
-
await writeFile('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
4558
|
+
await writeFile(join(process.cwd(), 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
4195
4559
|
log.dim(' manifest.json generated at project root (from app metadata)');
|
|
4196
4560
|
}
|
|
4197
4561
|
|