@dboio/cli 0.19.7 → 0.20.3
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/docs/dual-platform-maintenance.md +135 -0
- 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 +22 -19
- package/src/commands/clone.js +412 -57
- 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 +142 -43
- package/src/commands/status.js +15 -7
- package/src/lib/config.js +117 -11
- package/src/lib/dependencies.js +11 -9
- package/src/lib/filenames.js +54 -66
- package/src/lib/ignore.js +3 -0
- package/src/lib/input-parser.js +2 -6
- package/src/lib/insert.js +34 -49
- package/src/lib/structure.js +23 -8
- package/src/lib/ticketing.js +66 -9
- package/src/lib/toe-stepping.js +103 -3
- 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,18 +3,18 @@ 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';
|
|
16
16
|
import { fetchSchema, loadSchema, saveSchema, isSchemaStale } from '../lib/schema.js';
|
|
17
|
-
import { appMetadataPath } from '../lib/config.js';
|
|
17
|
+
import { appMetadataPath, baselinePath, metadataSchemaPath } from '../lib/config.js';
|
|
18
18
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
19
19
|
import { upsertDeployEntry } from '../lib/deploy-config.js';
|
|
20
20
|
import { syncDependencies, parseDependenciesColumn } from '../lib/dependencies.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) {
|
|
@@ -219,6 +250,114 @@ export async function detectAndTrashOrphans(appJson, ig, sync, options) {
|
|
|
219
250
|
}
|
|
220
251
|
}
|
|
221
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Use the explicit `appJson.deleted` map (returned by the server in delta/baseline responses)
|
|
255
|
+
* to find and trash local files for records the server has deleted.
|
|
256
|
+
*
|
|
257
|
+
* Unlike detectAndTrashOrphans() which diffs all local UIDs against all server UIDs,
|
|
258
|
+
* this function is authoritative: if the server says a UID was deleted, it moves the
|
|
259
|
+
* local files immediately — no full UID scan needed. This makes it safe to call in
|
|
260
|
+
* pull/delta mode where appJson.children may only contain changed records.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} appJson - App JSON possibly containing a `deleted` map
|
|
263
|
+
* @param {import('ignore').Ignore} ig - Ignore instance for findMetadataFiles
|
|
264
|
+
* @param {object} sync - Parsed synchronize.json { delete, edit, add }
|
|
265
|
+
* @param {object} options - Clone options
|
|
266
|
+
*/
|
|
267
|
+
export async function trashServerDeletedRecords(appJson, ig, sync, options) {
|
|
268
|
+
if (options.entityFilter) return;
|
|
269
|
+
if (!appJson?.deleted || typeof appJson.deleted !== 'object') return;
|
|
270
|
+
|
|
271
|
+
// Build set of UIDs to trash from all entities in deleted map
|
|
272
|
+
const deletedUids = new Map(); // uid → { entity, name }
|
|
273
|
+
for (const [entity, entries] of Object.entries(appJson.deleted)) {
|
|
274
|
+
if (!Array.isArray(entries)) continue;
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
if (entry?.UID) {
|
|
277
|
+
deletedUids.set(String(entry.UID), { entity, name: entry.Name || entry.UID });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (deletedUids.size === 0) return;
|
|
283
|
+
|
|
284
|
+
// UIDs already queued for deletion in synchronize.json — skip them
|
|
285
|
+
const stagedDeleteUids = new Set(
|
|
286
|
+
(sync.delete || []).map(e => e.UID).filter(Boolean).map(String)
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const metaFiles = await findMetadataFiles(process.cwd(), ig);
|
|
290
|
+
if (metaFiles.length === 0) return;
|
|
291
|
+
|
|
292
|
+
const trashDir = join(process.cwd(), 'trash');
|
|
293
|
+
const toTrash = [];
|
|
294
|
+
|
|
295
|
+
for (const metaPath of metaFiles) {
|
|
296
|
+
let meta;
|
|
297
|
+
try {
|
|
298
|
+
meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
299
|
+
} catch {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!meta.UID) continue;
|
|
304
|
+
const uid = String(meta.UID);
|
|
305
|
+
if (!deletedUids.has(uid)) continue;
|
|
306
|
+
if (stagedDeleteUids.has(uid)) continue;
|
|
307
|
+
|
|
308
|
+
const metaDir = dirname(metaPath);
|
|
309
|
+
const filesToMove = [metaPath];
|
|
310
|
+
|
|
311
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
312
|
+
const ref = meta[col];
|
|
313
|
+
if (ref && String(ref).startsWith('@')) {
|
|
314
|
+
const refName = String(ref).substring(1);
|
|
315
|
+
const companionPath = refName.startsWith('/')
|
|
316
|
+
? join(process.cwd(), refName)
|
|
317
|
+
: join(metaDir, refName);
|
|
318
|
+
if (await fileExists(companionPath)) filesToMove.push(companionPath);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
323
|
+
const refName = String(meta._mediaFile).substring(1);
|
|
324
|
+
const mediaPath = refName.startsWith('/')
|
|
325
|
+
? join(process.cwd(), refName)
|
|
326
|
+
: join(metaDir, refName);
|
|
327
|
+
if (await fileExists(mediaPath)) filesToMove.push(mediaPath);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const { entity } = deletedUids.get(uid);
|
|
331
|
+
toTrash.push({ metaPath, uid, entity, filesToMove });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (toTrash.length === 0) return;
|
|
335
|
+
|
|
336
|
+
await mkdir(trashDir, { recursive: true });
|
|
337
|
+
|
|
338
|
+
let trashed = 0;
|
|
339
|
+
for (const { metaPath, uid, entity, filesToMove } of toTrash) {
|
|
340
|
+
log.dim(` Trashed (server deleted): ${basename(metaPath)} (${entity}:${uid})`);
|
|
341
|
+
for (const filePath of filesToMove) {
|
|
342
|
+
const destBase = basename(filePath);
|
|
343
|
+
let destPath = join(trashDir, destBase);
|
|
344
|
+
try { await stat(destPath); destPath = `${destPath}.${Date.now()}`; } catch {}
|
|
345
|
+
try {
|
|
346
|
+
await rename(filePath, destPath);
|
|
347
|
+
trashed++;
|
|
348
|
+
} catch (err) {
|
|
349
|
+
log.warn(` Could not trash: ${filePath} — ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (trashed > 0) {
|
|
355
|
+
await ensureTrashIcon(trashDir);
|
|
356
|
+
log.plain('');
|
|
357
|
+
log.warn(`Moved ${toTrash.length} server-deleted record(s) to trash`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
222
361
|
/**
|
|
223
362
|
* Resolve a content Path to a directory under Bins/.
|
|
224
363
|
*
|
|
@@ -322,8 +461,9 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
|
|
|
322
461
|
const uid = String(record.UID || record._id || 'untitled');
|
|
323
462
|
// Companion: natural name, no UID
|
|
324
463
|
const filename = sanitizeFilename(buildContentFileName(record, uid));
|
|
325
|
-
// Metadata:
|
|
326
|
-
|
|
464
|
+
// Metadata: filename.metadata.json (includes extension to avoid collisions between records
|
|
465
|
+
// with the same Name but different Extension, e.g. codeTest.js vs codeTest.css)
|
|
466
|
+
const metaPath = join(dir, buildMetaFilename(filename));
|
|
327
467
|
|
|
328
468
|
return { dir, filename, metaPath };
|
|
329
469
|
}
|
|
@@ -352,10 +492,9 @@ export function resolveMediaPaths(record, structure) {
|
|
|
352
492
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
353
493
|
if (!dir) dir = BINS_DIR;
|
|
354
494
|
|
|
355
|
-
// Metadata: name.ext.metadata
|
|
356
|
-
const uid = String(record.UID || record._id || 'untitled');
|
|
495
|
+
// Metadata: name.ext.metadata.json
|
|
357
496
|
const naturalMediaBase = `${name}.${ext}`;
|
|
358
|
-
const metaPath = join(dir, buildMetaFilename(naturalMediaBase
|
|
497
|
+
const metaPath = join(dir, buildMetaFilename(naturalMediaBase));
|
|
359
498
|
|
|
360
499
|
return { dir, filename: companionFilename, metaPath };
|
|
361
500
|
}
|
|
@@ -397,8 +536,7 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
|
397
536
|
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
398
537
|
}
|
|
399
538
|
|
|
400
|
-
const
|
|
401
|
-
const metaPath = join(dirName, buildMetaFilename(name, uid));
|
|
539
|
+
const metaPath = join(dirName, buildMetaFilename(name));
|
|
402
540
|
return { dir: dirName, name, metaPath };
|
|
403
541
|
}
|
|
404
542
|
|
|
@@ -1197,11 +1335,42 @@ export async function performClone(source, options = {}) {
|
|
|
1197
1335
|
}
|
|
1198
1336
|
}
|
|
1199
1337
|
|
|
1338
|
+
// Save AppShortName to config before writing the metadata schema so that
|
|
1339
|
+
// metadataSchemaPath() resolves to <shortname>.metadata_schema.json rather
|
|
1340
|
+
// than falling back to the generic app.metadata_schema.json. Without this,
|
|
1341
|
+
// processExtensionEntries() later loads null (wrong file) and descriptor
|
|
1342
|
+
// sub-directories + companion @reference entries are lost.
|
|
1343
|
+
if (!options.pullMode && appJson?.ShortName) {
|
|
1344
|
+
// If the app's ShortName changed, rename the .app/ files that are keyed by it
|
|
1345
|
+
// before updating config so the old paths can still be resolved.
|
|
1346
|
+
const oldShortName = config.AppShortName;
|
|
1347
|
+
const newShortName = appJson.ShortName;
|
|
1348
|
+
if (oldShortName && oldShortName !== newShortName) {
|
|
1349
|
+
const oldBaseline = await baselinePath();
|
|
1350
|
+
const oldAppMeta = await appMetadataPath();
|
|
1351
|
+
const oldSchema = await metadataSchemaPath();
|
|
1352
|
+
await updateConfigWithApp({ AppShortName: newShortName });
|
|
1353
|
+
const newBaseline = await baselinePath();
|
|
1354
|
+
const newAppMeta = await appMetadataPath();
|
|
1355
|
+
const newSchema = await metadataSchemaPath();
|
|
1356
|
+
for (const [oldPath, newPath] of [[oldBaseline, newBaseline], [oldAppMeta, newAppMeta], [oldSchema, newSchema]]) {
|
|
1357
|
+
try { await access(oldPath); await rename(oldPath, newPath); log.dim(` Renamed ${basename(oldPath)} → ${basename(newPath)}`); } catch { /* file absent, nothing to rename */ }
|
|
1358
|
+
}
|
|
1359
|
+
} else {
|
|
1360
|
+
await updateConfigWithApp({ AppShortName: newShortName });
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1200
1364
|
// Regenerate metadata_schema.json for any new entity types
|
|
1201
1365
|
if (schema) {
|
|
1202
1366
|
const existing = await loadMetadataSchema();
|
|
1203
1367
|
const updated = generateMetadataFromSchema(schema, existing ?? {});
|
|
1204
1368
|
await saveMetadataSchema(updated);
|
|
1369
|
+
// Remove orphaned app.metadata_schema.json left by previous runs that wrote
|
|
1370
|
+
// the schema before AppShortName was saved (entity entries are regenerated above).
|
|
1371
|
+
if (appJson?.ShortName && appJson.ShortName !== 'app') {
|
|
1372
|
+
try { await unlink(join('.app', 'app.metadata_schema.json')); } catch { /* not present */ }
|
|
1373
|
+
}
|
|
1205
1374
|
}
|
|
1206
1375
|
|
|
1207
1376
|
// Domain change detection
|
|
@@ -1222,7 +1391,7 @@ export async function performClone(source, options = {}) {
|
|
|
1222
1391
|
|
|
1223
1392
|
// Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
|
|
1224
1393
|
if (!isDependencyCheckout()) {
|
|
1225
|
-
await ensureGitignore(
|
|
1394
|
+
await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
|
|
1226
1395
|
}
|
|
1227
1396
|
|
|
1228
1397
|
// Step 2: Update .app/config.json (skip in pull mode — config already set)
|
|
@@ -1415,7 +1584,7 @@ export async function performClone(source, options = {}) {
|
|
|
1415
1584
|
|
|
1416
1585
|
// Step 5a: Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to project root.
|
|
1417
1586
|
// Also fixes the duplicate bug: relocates companions from lib/bins/app/ to root and rewrites metadata.
|
|
1418
|
-
if (
|
|
1587
|
+
if (!entityFilter || entityFilter.has('content')) {
|
|
1419
1588
|
await writeRootContentFiles(appJson, contentRefs);
|
|
1420
1589
|
}
|
|
1421
1590
|
|
|
@@ -1482,6 +1651,9 @@ export async function performClone(source, options = {}) {
|
|
|
1482
1651
|
if (!entityFilter) {
|
|
1483
1652
|
const ig = await loadIgnore();
|
|
1484
1653
|
const sync = await loadSynchronize();
|
|
1654
|
+
// Use explicit deleted list from server first (authoritative, works in delta/pull mode)
|
|
1655
|
+
await trashServerDeletedRecords(appJson, ig, sync, { ...options, entityFilter });
|
|
1656
|
+
// Fall back to full UID diff for records absent from server but not in deleted list
|
|
1485
1657
|
await detectAndTrashOrphans(appJson, ig, sync, { ...options, entityFilter });
|
|
1486
1658
|
}
|
|
1487
1659
|
|
|
@@ -1978,14 +2150,15 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1978
2150
|
}
|
|
1979
2151
|
|
|
1980
2152
|
// Resolve name collisions: second+ record with same name gets -1, -2, etc.
|
|
2153
|
+
// Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
|
|
1981
2154
|
const uid = record.UID || 'untitled';
|
|
1982
|
-
const nameKey = name;
|
|
2155
|
+
const nameKey = name.toLowerCase();
|
|
1983
2156
|
const count = usedNames.get(nameKey) || 0;
|
|
1984
2157
|
usedNames.set(nameKey, count + 1);
|
|
1985
2158
|
if (count > 0) name = `${name}-${count}`;
|
|
1986
2159
|
|
|
1987
|
-
// Metadata: name.metadata
|
|
1988
|
-
const metaPath = join(dirName, buildMetaFilename(name
|
|
2160
|
+
// Metadata: name.metadata.json; companion files use natural name
|
|
2161
|
+
const metaPath = join(dirName, buildMetaFilename(name));
|
|
1989
2162
|
|
|
1990
2163
|
// Legacy detection: rename old-format metadata files to new convention
|
|
1991
2164
|
const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
|
|
@@ -2031,7 +2204,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
2031
2204
|
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
2032
2205
|
const willDeleteEntityMeta = join(dirName, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
|
|
2033
2206
|
const entityMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteEntityMeta);
|
|
2034
|
-
if (entityMetaExists && !options.yes && !hasNewExtractions) {
|
|
2207
|
+
if (entityMetaExists && !options.yes && !options.force && !hasNewExtractions) {
|
|
2035
2208
|
if (bulkAction.value === 'skip_all') {
|
|
2036
2209
|
log.dim(` Skipped ${name}`);
|
|
2037
2210
|
refs.push({ uid: record.UID, metaPath });
|
|
@@ -2217,7 +2390,7 @@ function parseFormControlCode(string5) {
|
|
|
2217
2390
|
if (codeStr) {
|
|
2218
2391
|
for (const pair of codeStr.split(',')) {
|
|
2219
2392
|
const [col, ext] = pair.split('|');
|
|
2220
|
-
if (col?.trim()
|
|
2393
|
+
if (col?.trim()) colToExt.set(col.trim(), ext?.trim().toLowerCase() || 'md');
|
|
2221
2394
|
}
|
|
2222
2395
|
}
|
|
2223
2396
|
const titleStr = params.get('form-control-title');
|
|
@@ -2284,9 +2457,10 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
|
|
|
2284
2457
|
const { colToExt, colToTitle } = parseFormControlCode(string5);
|
|
2285
2458
|
if (colToExt.size === 0) continue;
|
|
2286
2459
|
|
|
2287
|
-
// Only
|
|
2460
|
+
// Only skip if the existing entry already has @reference expressions;
|
|
2461
|
+
// plain-column entries (seeded without form-control-code) should be overwritten
|
|
2288
2462
|
const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
|
|
2289
|
-
if (existing) continue;
|
|
2463
|
+
if (existing?.some(e => e.includes('@reference'))) continue;
|
|
2290
2464
|
|
|
2291
2465
|
const refEntries = [];
|
|
2292
2466
|
for (const [col, ext] of colToExt) {
|
|
@@ -2469,8 +2643,6 @@ function guessExtensionForDescriptor(descriptor, columnName) {
|
|
|
2469
2643
|
* @returns {Promise<'inline'|'root'>}
|
|
2470
2644
|
*/
|
|
2471
2645
|
async function resolveDocumentationPlacement(options) {
|
|
2472
|
-
if (options.yes) return 'inline';
|
|
2473
|
-
|
|
2474
2646
|
const saved = await loadExtensionDocumentationMDPlacement();
|
|
2475
2647
|
if (saved && !options.force && !options.configure) {
|
|
2476
2648
|
log.dim(` Documentation MD placement: ${saved} (saved)`);
|
|
@@ -2478,7 +2650,7 @@ async function resolveDocumentationPlacement(options) {
|
|
|
2478
2650
|
}
|
|
2479
2651
|
|
|
2480
2652
|
let placement;
|
|
2481
|
-
if (options.configure) {
|
|
2653
|
+
if (options.configure && !options.yes) {
|
|
2482
2654
|
const inquirer = (await import('inquirer')).default;
|
|
2483
2655
|
({ placement } = await inquirer.prompt([{
|
|
2484
2656
|
type: 'list', name: 'placement',
|
|
@@ -2520,7 +2692,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2520
2692
|
log.info(`Processing ${entries.length} extension record(s)...`);
|
|
2521
2693
|
|
|
2522
2694
|
// Step A: Pre-pass — build mapping + create directories
|
|
2523
|
-
const metadataSchema = await loadMetadataSchema();
|
|
2695
|
+
const metadataSchema = (await loadMetadataSchema()) ?? {};
|
|
2524
2696
|
const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
|
|
2525
2697
|
|
|
2526
2698
|
// Clear documentation preferences when --force is used with --documentation-only
|
|
@@ -2568,12 +2740,19 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2568
2740
|
}
|
|
2569
2741
|
|
|
2570
2742
|
// Step D: Write files, one group at a time
|
|
2743
|
+
// descriptor_definition must be written before dependent descriptors (e.g. control)
|
|
2571
2744
|
const refs = [];
|
|
2572
2745
|
const bulkAction = { value: null };
|
|
2573
2746
|
const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
|
|
2574
2747
|
const config = await loadConfig();
|
|
2575
2748
|
|
|
2576
|
-
|
|
2749
|
+
const sortedGroups = [...groups.entries()].sort(([a], [b]) => {
|
|
2750
|
+
if (a === 'descriptor_definition') return -1;
|
|
2751
|
+
if (b === 'descriptor_definition') return 1;
|
|
2752
|
+
return 0;
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
for (const [descriptor, { dir, records }] of sortedGroups) {
|
|
2577
2756
|
const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
|
|
2578
2757
|
const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
|
|
2579
2758
|
const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
|
|
@@ -2593,14 +2772,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2593
2772
|
}
|
|
2594
2773
|
|
|
2595
2774
|
// Resolve name collisions: second+ record with same name gets -1, -2, etc.
|
|
2775
|
+
// Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
|
|
2596
2776
|
const uid = record.UID || 'untitled';
|
|
2597
|
-
const nameKey = name;
|
|
2777
|
+
const nameKey = name.toLowerCase();
|
|
2598
2778
|
const nameCount = usedNames.get(nameKey) || 0;
|
|
2599
2779
|
usedNames.set(nameKey, nameCount + 1);
|
|
2600
2780
|
if (nameCount > 0) name = `${name}-${nameCount}`;
|
|
2601
2781
|
|
|
2602
|
-
// Metadata: name.metadata
|
|
2603
|
-
const metaPath = join(dir, buildMetaFilename(name
|
|
2782
|
+
// Metadata: name.metadata.json; companion files use natural name
|
|
2783
|
+
const metaPath = join(dir, buildMetaFilename(name));
|
|
2604
2784
|
|
|
2605
2785
|
// Legacy detection: rename old-format metadata files to new convention
|
|
2606
2786
|
const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
|
|
@@ -2819,7 +2999,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2819
2999
|
const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
|
|
2820
3000
|
const resolvedName = resolvedFilenames.get(record.UID);
|
|
2821
3001
|
const effectiveMetaPath = resolvedName
|
|
2822
|
-
? join(scanDir, buildMetaFilename(resolvedName
|
|
3002
|
+
? join(scanDir, buildMetaFilename(resolvedName))
|
|
2823
3003
|
: scanMetaPath;
|
|
2824
3004
|
if (!(await fileExists(effectiveMetaPath))) {
|
|
2825
3005
|
const staleMeta = { _entity: 'media', _foreignApp: true };
|
|
@@ -2881,7 +3061,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2881
3061
|
// Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
|
|
2882
3062
|
const resolvedName = resolvedFilenames.get(record.UID);
|
|
2883
3063
|
const effectiveMetaPath = resolvedName
|
|
2884
|
-
? join(scanDir, buildMetaFilename(resolvedName
|
|
3064
|
+
? join(scanDir, buildMetaFilename(resolvedName))
|
|
2885
3065
|
: scanMetaPath;
|
|
2886
3066
|
const scanExists = await fileExists(effectiveMetaPath);
|
|
2887
3067
|
|
|
@@ -2971,12 +3151,11 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2971
3151
|
if (!dir) dir = BINS_DIR;
|
|
2972
3152
|
await mkdir(dir, { recursive: true });
|
|
2973
3153
|
|
|
2974
|
-
// Companion: natural name
|
|
2975
|
-
const uid = String(record.UID || record._id || 'untitled');
|
|
3154
|
+
// Companion: natural name (use collision-resolved override if available)
|
|
2976
3155
|
const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
|
|
2977
3156
|
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
|
|
3157
|
+
// Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata.json")
|
|
3158
|
+
const metaPath = join(dir, buildMetaFilename(finalFilename));
|
|
2980
3159
|
// usedNames retained for tracking
|
|
2981
3160
|
const fileKey = `${dir}/${name}.${ext}`;
|
|
2982
3161
|
usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
|
|
@@ -3248,7 +3427,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3248
3427
|
probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
|
|
3249
3428
|
if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
|
|
3250
3429
|
|
|
3251
|
-
const probeMeta = join(probeDir, buildMetaFilename(sanitized
|
|
3430
|
+
const probeMeta = join(probeDir, buildMetaFilename(sanitized));
|
|
3252
3431
|
const raw = await readFile(probeMeta, 'utf8');
|
|
3253
3432
|
const localMeta = JSON.parse(raw);
|
|
3254
3433
|
// Extract extension from Content @reference (e.g. "@Name~uid.html")
|
|
@@ -3339,8 +3518,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3339
3518
|
const uid = String(record.UID || record._id || 'untitled');
|
|
3340
3519
|
// Companion: natural name, no UID (use collision-resolved override if available)
|
|
3341
3520
|
const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
|
|
3342
|
-
// Metadata:
|
|
3343
|
-
//
|
|
3521
|
+
// Metadata: filename.metadata.json (includes extension to avoid collisions between records
|
|
3522
|
+
// with the same Name but different Extension, e.g. codeTest.js vs codeTest.css)
|
|
3344
3523
|
const nameKey = `${dir}/${name}`;
|
|
3345
3524
|
usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
|
|
3346
3525
|
|
|
@@ -3352,7 +3531,20 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3352
3531
|
);
|
|
3353
3532
|
|
|
3354
3533
|
const filePath = join(dir, fileName);
|
|
3355
|
-
const metaPath = join(dir, buildMetaFilename(
|
|
3534
|
+
const metaPath = join(dir, buildMetaFilename(fileName));
|
|
3535
|
+
|
|
3536
|
+
// Legacy migration: rename old name.metadata.json → new filename.metadata.json
|
|
3537
|
+
// (repos cloned before this fix used the base name without extension as the metadata stem)
|
|
3538
|
+
const legacyMetaPath = join(dir, buildMetaFilename(name));
|
|
3539
|
+
if (legacyMetaPath !== metaPath && !await fileExists(metaPath) && await fileExists(legacyMetaPath)) {
|
|
3540
|
+
try {
|
|
3541
|
+
const legacyMeta = JSON.parse(await readFile(legacyMetaPath, 'utf8'));
|
|
3542
|
+
if (legacyMeta.UID === uid) {
|
|
3543
|
+
const { rename: fsRename } = await import('fs/promises');
|
|
3544
|
+
await fsRename(legacyMetaPath, metaPath);
|
|
3545
|
+
}
|
|
3546
|
+
} catch { /* non-critical */ }
|
|
3547
|
+
}
|
|
3356
3548
|
|
|
3357
3549
|
// Rename legacy ~UID companion files to natural names if needed
|
|
3358
3550
|
if (await fileExists(metaPath)) {
|
|
@@ -4081,22 +4273,32 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
4081
4273
|
// Build root output filename (natural name, no UID in stem)
|
|
4082
4274
|
const rootBasename = buildOutputFilename('output', output, filenameCols.output);
|
|
4083
4275
|
const rootUid = output.UID || '';
|
|
4084
|
-
|
|
4276
|
+
|
|
4277
|
+
// Resolve metadata path with collision detection (content records have priority over output).
|
|
4278
|
+
// If name.metadata.json is owned by a different UID (e.g. a content record), use -1, -2, etc.
|
|
4279
|
+
let rootMetaPath = await resolveMetaCollision(binDir, rootBasename, rootUid);
|
|
4085
4280
|
|
|
4086
4281
|
// Legacy fallback: rename old-format metadata to new convention
|
|
4087
4282
|
const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
|
|
4088
4283
|
const legacyJsonPath = join(binDir, `${rootBasename}.json`);
|
|
4089
|
-
const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
|
|
4090
4284
|
if (!await fileExists(rootMetaPath)) {
|
|
4091
4285
|
if (await fileExists(legacyTildeOutputMeta)) {
|
|
4092
4286
|
await rename(legacyTildeOutputMeta, rootMetaPath);
|
|
4093
4287
|
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
4288
|
} else if (await fileExists(legacyJsonPath)) {
|
|
4098
|
-
|
|
4099
|
-
|
|
4289
|
+
// Only rename if the .json file actually looks like output metadata JSON.
|
|
4290
|
+
// Content companions can share the same {name}.json filename pattern —
|
|
4291
|
+
// renaming those would corrupt the content record's companion file.
|
|
4292
|
+
let isOutputMeta = false;
|
|
4293
|
+
try {
|
|
4294
|
+
const legacyContent = await readFile(legacyJsonPath, 'utf8');
|
|
4295
|
+
const legacyParsed = JSON.parse(legacyContent);
|
|
4296
|
+
isOutputMeta = legacyParsed && (legacyParsed._entity === 'output' || legacyParsed.OutputID != null);
|
|
4297
|
+
} catch { /* not valid JSON — definitely a content companion, skip */ }
|
|
4298
|
+
if (isOutputMeta) {
|
|
4299
|
+
await rename(legacyJsonPath, rootMetaPath);
|
|
4300
|
+
log.dim(` Renamed ${rootBasename}.json → ${basename(rootMetaPath)}`);
|
|
4301
|
+
}
|
|
4100
4302
|
}
|
|
4101
4303
|
}
|
|
4102
4304
|
|
|
@@ -4206,28 +4408,34 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
4206
4408
|
/**
|
|
4207
4409
|
* Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to the project root.
|
|
4208
4410
|
*
|
|
4209
|
-
*
|
|
4210
|
-
*
|
|
4211
|
-
*
|
|
4212
|
-
*
|
|
4213
|
-
*
|
|
4214
|
-
*
|
|
4411
|
+
* Two passes:
|
|
4412
|
+
* 1. For each filename in rootContentFiles config: search contentRefs for a matching record,
|
|
4413
|
+
* relocate its companion to the project root, and rewrite the metadata to use
|
|
4414
|
+
* Content: "@/<filename>" (root-relative). Fall back to a stub if no server record.
|
|
4415
|
+
* 2. Promote any remaining content records with BinID=null whose companion filename is also
|
|
4416
|
+
* in rootContentFiles (catches records not matched by pass 1's name/content/path heuristics).
|
|
4215
4417
|
*/
|
|
4216
4418
|
async function writeRootContentFiles(appJson, contentRefs) {
|
|
4217
4419
|
const rootFiles = await loadRootContentFiles();
|
|
4218
|
-
|
|
4420
|
+
const handledUids = new Set();
|
|
4219
4421
|
|
|
4220
4422
|
for (const filename of rootFiles) {
|
|
4221
|
-
const
|
|
4222
|
-
if (
|
|
4423
|
+
const handledUid = await _writeRootFile(filename, appJson, contentRefs);
|
|
4424
|
+
if (handledUid) {
|
|
4425
|
+
handledUids.add(handledUid);
|
|
4426
|
+
} else {
|
|
4223
4427
|
await _generateRootFileStub(filename, appJson);
|
|
4224
4428
|
}
|
|
4225
4429
|
}
|
|
4430
|
+
|
|
4431
|
+
// Promote any content records with no BinID that weren't already handled above,
|
|
4432
|
+
// but only if the companion filename appears in rootContentFiles.
|
|
4433
|
+
await _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles);
|
|
4226
4434
|
}
|
|
4227
4435
|
|
|
4228
4436
|
/**
|
|
4229
4437
|
* Find the content record for a root file and relocate its companion to the project root.
|
|
4230
|
-
* Returns
|
|
4438
|
+
* Returns the record UID if handled, null if no matching record was found.
|
|
4231
4439
|
*/
|
|
4232
4440
|
async function _writeRootFile(filename, appJson, contentRefs) {
|
|
4233
4441
|
const filenameLower = filename.toLowerCase();
|
|
@@ -4283,10 +4491,69 @@ async function _writeRootFile(filename, appJson, contentRefs) {
|
|
|
4283
4491
|
await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
|
|
4284
4492
|
} catch { /* non-critical */ }
|
|
4285
4493
|
|
|
4286
|
-
return
|
|
4494
|
+
return ref.uid;
|
|
4287
4495
|
}
|
|
4288
4496
|
|
|
4289
|
-
return
|
|
4497
|
+
return null; // No server record found
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
/**
|
|
4501
|
+
* Promote content records with no BinID to the project root.
|
|
4502
|
+
* Only promotes records whose companion filename appears in rootContentFiles —
|
|
4503
|
+
* records not in that list stay in their lib/bins/ placement.
|
|
4504
|
+
* Skips records already handled by _writeRootFile (tracked via handledUids).
|
|
4505
|
+
* Companion filename is extracted from the Content @-reference in the metadata.
|
|
4506
|
+
*/
|
|
4507
|
+
async function _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles) {
|
|
4508
|
+
const rootFilesLower = new Set((rootFiles || []).map(f => f.toLowerCase()));
|
|
4509
|
+
for (const ref of contentRefs) {
|
|
4510
|
+
if (handledUids.has(ref.uid)) continue;
|
|
4511
|
+
|
|
4512
|
+
let meta;
|
|
4513
|
+
try { meta = JSON.parse(await readFile(ref.metaPath, 'utf8')); } catch { continue; }
|
|
4514
|
+
|
|
4515
|
+
// Only promote records with no BinID
|
|
4516
|
+
if (meta.BinID != null) continue;
|
|
4517
|
+
|
|
4518
|
+
// Extract companion filename from Content @-reference
|
|
4519
|
+
const metaContent = String(meta.Content || '');
|
|
4520
|
+
const contentRef = metaContent.startsWith('@/') ? metaContent.slice(2)
|
|
4521
|
+
: metaContent.startsWith('@') ? metaContent.slice(1)
|
|
4522
|
+
: null;
|
|
4523
|
+
|
|
4524
|
+
// Skip if no companion reference, if it includes a path (not a root-level file),
|
|
4525
|
+
// or if it's not listed in rootContentFiles.
|
|
4526
|
+
if (!contentRef || contentRef.includes('/')) continue;
|
|
4527
|
+
if (!rootFilesLower.has(contentRef.toLowerCase())) continue;
|
|
4528
|
+
|
|
4529
|
+
const filename = contentRef;
|
|
4530
|
+
const metaDir = dirname(ref.metaPath);
|
|
4531
|
+
const localCompanion = join(metaDir, filename);
|
|
4532
|
+
let content;
|
|
4533
|
+
try {
|
|
4534
|
+
content = await readFile(localCompanion, 'utf8');
|
|
4535
|
+
} catch {
|
|
4536
|
+
try { content = await readFile(join(process.cwd(), filename), 'utf8'); } catch { /* nothing */ }
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
if (content !== undefined) {
|
|
4540
|
+
await writeFile(join(process.cwd(), filename), content);
|
|
4541
|
+
log.dim(` ${filename} written to project root (BinID=null)`);
|
|
4542
|
+
|
|
4543
|
+
if (localCompanion !== join(process.cwd(), filename)) {
|
|
4544
|
+
try { await unlink(localCompanion); } catch { /* already gone or at root */ }
|
|
4545
|
+
}
|
|
4546
|
+
} else {
|
|
4547
|
+
log.warn(` Could not find companion file for ${filename} (BinID=null record)`);
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
// Rewrite metadata: root-relative Content reference
|
|
4551
|
+
try {
|
|
4552
|
+
const updated = { ...meta, Content: `@/${filename}` };
|
|
4553
|
+
if (!updated._companionReferenceColumns) updated._companionReferenceColumns = ['Content'];
|
|
4554
|
+
await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
|
|
4555
|
+
} catch { /* non-critical */ }
|
|
4556
|
+
}
|
|
4290
4557
|
}
|
|
4291
4558
|
|
|
4292
4559
|
/**
|
|
@@ -4307,14 +4574,76 @@ async function _generateRootFileStub(filename, appJson) {
|
|
|
4307
4574
|
}
|
|
4308
4575
|
|
|
4309
4576
|
if (filenameLower === 'claude.md') {
|
|
4310
|
-
const
|
|
4577
|
+
const cfg = await loadConfig();
|
|
4578
|
+
const domain = cfg.domain || '';
|
|
4579
|
+
const appShortName = appJson.ShortName || '';
|
|
4580
|
+
const siteRecords = appJson.children?.site || [];
|
|
4581
|
+
const siteLines = siteRecords.map(s => {
|
|
4582
|
+
const url = `//${domain}/app/${appShortName}/${s.ShortName}`;
|
|
4583
|
+
const label = s.Title || s.Name || s.ShortName;
|
|
4584
|
+
return `- \`${url}\` — ${label} (add \`?dev=true\` to serve uncompiled JS; add \`&console=true\` for verbose debug logging)`;
|
|
4585
|
+
});
|
|
4586
|
+
const stub = [
|
|
4587
|
+
`# ${appName}`,
|
|
4588
|
+
...(siteLines.length > 0 ? [``, `## App Sites`, ``, ...siteLines] : []),
|
|
4589
|
+
``,
|
|
4590
|
+
`## DBO CLI`,
|
|
4591
|
+
``,
|
|
4592
|
+
`Always run \`dbo\` commands from the project root (the directory containing this CLAUDE.md file).`,
|
|
4593
|
+
``,
|
|
4594
|
+
`## DBO API Submissions`,
|
|
4595
|
+
``,
|
|
4596
|
+
`- To create new records, use the REST API (\`/api/input/submit\`) directly — the CLI has no \`add\` command.`,
|
|
4597
|
+
`- 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.`,
|
|
4598
|
+
`- Cookie file for auth: \`.app/cookies.txt\``,
|
|
4599
|
+
`- 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.`,
|
|
4600
|
+
`- Domain: read from \`.app/config.json\` (\`domain\` field)`,
|
|
4601
|
+
``,
|
|
4602
|
+
`## Documentation`,
|
|
4603
|
+
``,
|
|
4604
|
+
`Project docs may live in any of these locations:`,
|
|
4605
|
+
``,
|
|
4606
|
+
`- \`CLAUDE.md\` _(this file)_`,
|
|
4607
|
+
`- \`README.md\``,
|
|
4608
|
+
`- \`docs/\``,
|
|
4609
|
+
`- \`lib/bins/docs/\``,
|
|
4610
|
+
`- \`lib/extension/Documentation/\``,
|
|
4611
|
+
``,
|
|
4612
|
+
`For dependency apps, look under \`app_dependencies/<app_short_name>/\` using the same structure:`,
|
|
4613
|
+
``,
|
|
4614
|
+
`- \`app_dependencies/<app_short_name>/CLAUDE.md\``,
|
|
4615
|
+
`- \`app_dependencies/<app_short_name>/README.md\``,
|
|
4616
|
+
`- \`app_dependencies/<app_short_name>/docs/\``,
|
|
4617
|
+
`- \`app_dependencies/<app_short_name>/lib/bins/docs/\``,
|
|
4618
|
+
`- \`app_dependencies/<app_short_name>/lib/extension/Documentation/\``,
|
|
4619
|
+
``,
|
|
4620
|
+
`### DBO.io Framework API Reference`,
|
|
4621
|
+
``,
|
|
4622
|
+
`For the DBO.io REST API (input/submit, output, content, etc.), check in priority order:`,
|
|
4623
|
+
``,
|
|
4624
|
+
`1. \`app_dependencies/_system/lib/bins/docs/\` — authoritative source`,
|
|
4625
|
+
`2. \`plugins/claude/dbo/skills/white-paper/references/\` — fallback reference`,
|
|
4626
|
+
``,
|
|
4627
|
+
].join('\n');
|
|
4311
4628
|
await writeFile(rootPath, stub);
|
|
4312
4629
|
log.dim(` CLAUDE.md generated at project root (stub)`);
|
|
4313
4630
|
return;
|
|
4314
4631
|
}
|
|
4315
4632
|
|
|
4316
4633
|
if (filenameLower === 'readme.md') {
|
|
4634
|
+
const cfg = await loadConfig();
|
|
4635
|
+
const domain = cfg.domain || '';
|
|
4636
|
+
const appShortName = appJson.ShortName || '';
|
|
4637
|
+
const siteRecords = appJson.children?.site || [];
|
|
4317
4638
|
const parts = [`# ${appName}`];
|
|
4639
|
+
if (siteRecords.length > 0) {
|
|
4640
|
+
parts.push('');
|
|
4641
|
+
for (const s of siteRecords) {
|
|
4642
|
+
const url = `//${domain}/app/${appShortName}/${s.ShortName}`;
|
|
4643
|
+
const label = s.Title || s.Name || s.ShortName;
|
|
4644
|
+
parts.push(`- [${label}](${url})`);
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4318
4647
|
if (description) parts.push('', description);
|
|
4319
4648
|
parts.push('');
|
|
4320
4649
|
await writeFile(rootPath, parts.join('\n'));
|
|
@@ -4322,6 +4651,32 @@ async function _generateRootFileStub(filename, appJson) {
|
|
|
4322
4651
|
return;
|
|
4323
4652
|
}
|
|
4324
4653
|
|
|
4654
|
+
if (filenameLower === 'package.json') {
|
|
4655
|
+
const shortName = (appJson.ShortName || appName || 'app').toLowerCase().replace(/\s+/g, '-');
|
|
4656
|
+
const stub = JSON.stringify({
|
|
4657
|
+
name: shortName,
|
|
4658
|
+
version: '1.0.0',
|
|
4659
|
+
description: description || '',
|
|
4660
|
+
private: true,
|
|
4661
|
+
}, null, 2) + '\n';
|
|
4662
|
+
await writeFile(rootPath, stub);
|
|
4663
|
+
log.dim(` package.json generated at project root (stub)`);
|
|
4664
|
+
return;
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
if (filenameLower === '.dboignore') {
|
|
4668
|
+
await writeFile(rootPath, getDboignoreDefaultContent());
|
|
4669
|
+
log.dim(` .dboignore generated at project root (stub)`);
|
|
4670
|
+
return;
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
if (filenameLower === '.gitignore') {
|
|
4674
|
+
const lines = DEFAULT_GITIGNORE_ENTRIES.map(e => e).join('\n') + '\n';
|
|
4675
|
+
await writeFile(rootPath, lines);
|
|
4676
|
+
log.dim(` .gitignore generated at project root (stub)`);
|
|
4677
|
+
return;
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4325
4680
|
// Unknown file type — no stub generated
|
|
4326
4681
|
}
|
|
4327
4682
|
|