@dboio/cli 0.11.4 → 0.15.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 +183 -3
- package/bin/dbo.js +6 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +66 -243
- package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
- package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
- package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
- package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
- package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
- package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
- package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
- package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
- package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
- package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
- package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
- package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
- package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
- package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +2279 -0
- package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
- package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
- package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
- package/plugins/claude/dbo/skills/cli/SKILL.md +63 -246
- package/src/commands/add.js +373 -64
- package/src/commands/build.js +102 -0
- package/src/commands/clone.js +719 -212
- package/src/commands/deploy.js +9 -2
- package/src/commands/diff.js +7 -3
- package/src/commands/init.js +16 -2
- package/src/commands/input.js +3 -1
- package/src/commands/login.js +30 -4
- package/src/commands/mv.js +28 -7
- package/src/commands/push.js +298 -78
- package/src/commands/rm.js +21 -6
- package/src/commands/run.js +81 -0
- package/src/commands/tag.js +65 -0
- package/src/lib/config.js +67 -0
- package/src/lib/delta.js +7 -1
- package/src/lib/deploy-config.js +137 -0
- package/src/lib/diff.js +28 -5
- package/src/lib/filenames.js +198 -54
- package/src/lib/ignore.js +6 -0
- package/src/lib/input-parser.js +13 -4
- package/src/lib/scaffold.js +1 -1
- package/src/lib/scripts.js +232 -0
- package/src/lib/tagging.js +380 -0
- package/src/lib/toe-stepping.js +2 -1
- package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
- package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
- package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
package/src/commands/add.js
CHANGED
|
@@ -1,20 +1,59 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, readdir, stat, writeFile, mkdir,
|
|
2
|
+
import { readFile, readdir, stat, writeFile, mkdir, unlink } from 'fs/promises';
|
|
3
3
|
import { join, dirname, basename, extname, relative } from 'path';
|
|
4
4
|
import { DboClient } from '../lib/client.js';
|
|
5
5
|
import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
|
|
6
6
|
import { formatResponse, formatError } from '../lib/formatter.js';
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
8
|
import { shouldSkipColumn } from '../lib/columns.js';
|
|
9
|
-
import { loadAppConfig, loadAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from '../lib/config.js';
|
|
9
|
+
import { loadAppConfig, loadAppJsonBaseline, saveAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from '../lib/config.js';
|
|
10
10
|
import { resolveDirective, resolveTemplateCols, assembleMetadata, promptReferenceColumn, setTemplateCols, saveMetadataTemplates, loadMetadataTemplates } from '../lib/metadata-templates.js';
|
|
11
|
-
import {
|
|
11
|
+
import { hasUidInFilename, buildMetaFilename, isMetadataFile } from '../lib/filenames.js';
|
|
12
12
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
13
13
|
import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
|
|
14
14
|
import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
|
|
15
15
|
import { loadIgnore } from '../lib/ignore.js';
|
|
16
|
-
import { loadStructureFile, findBinByPath } from '../lib/structure.js';
|
|
16
|
+
import { loadStructureFile, findBinByPath, BINS_DIR } from '../lib/structure.js';
|
|
17
17
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
18
|
+
import { upsertDeployEntry } from '../lib/deploy-config.js';
|
|
19
|
+
|
|
20
|
+
// ─── File type classification for auto-detection in bin directories ────────
|
|
21
|
+
|
|
22
|
+
/** Extensions treated as content entities (text-based, stored in content.Content) */
|
|
23
|
+
const CONTENT_EXTENSIONS = new Set([
|
|
24
|
+
'sh', 'js', 'json', 'html', 'txt', 'xml', 'css', 'md',
|
|
25
|
+
'htm', 'svg', 'sql', 'csv', 'yaml', 'yml', 'ts', 'jsx', 'tsx',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Extensions treated as media entities (binary files: images, audio, video) */
|
|
29
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
30
|
+
// Images
|
|
31
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif',
|
|
32
|
+
// Audio
|
|
33
|
+
'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
|
|
34
|
+
// Video
|
|
35
|
+
'mp4', 'webm', 'mov', 'avi', 'mkv', 'wmv', 'flv',
|
|
36
|
+
// Fonts
|
|
37
|
+
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
|
38
|
+
// Documents / archives (binary)
|
|
39
|
+
'pdf', 'zip', 'gz', 'tar',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Simple MIME type lookup for common media extensions */
|
|
43
|
+
const MIME_TYPES = {
|
|
44
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
45
|
+
webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', svg: 'image/svg+xml',
|
|
46
|
+
tiff: 'image/tiff', tif: 'image/tiff', avif: 'image/avif',
|
|
47
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', aac: 'audio/aac',
|
|
48
|
+
flac: 'audio/flac', m4a: 'audio/mp4', wma: 'audio/x-ms-wma',
|
|
49
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|
50
|
+
avi: 'video/x-msvideo', mkv: 'video/x-matroska', wmv: 'video/x-ms-wmv',
|
|
51
|
+
flv: 'video/x-flv',
|
|
52
|
+
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
|
|
53
|
+
eot: 'application/vnd.ms-fontobject',
|
|
54
|
+
pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
|
|
55
|
+
tar: 'application/x-tar',
|
|
56
|
+
};
|
|
18
57
|
|
|
19
58
|
export const addCommand = new Command('add')
|
|
20
59
|
.description('Add a new file to DBO.io (creates record on server)')
|
|
@@ -118,6 +157,165 @@ async function detectManifestFile(filePath) {
|
|
|
118
157
|
return { meta, metaPath };
|
|
119
158
|
}
|
|
120
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Detect whether a file lives inside lib/bins/ and auto-classify as content or media.
|
|
162
|
+
*
|
|
163
|
+
* Content extensions: sh, js, json, html, txt, xml, css, md, etc.
|
|
164
|
+
* Media extensions: png, jpg, webp, mp4, woff2, pdf, etc.
|
|
165
|
+
*
|
|
166
|
+
* Returns { meta, metaPath } or null if not applicable.
|
|
167
|
+
*/
|
|
168
|
+
export async function detectBinFile(filePath) {
|
|
169
|
+
const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
170
|
+
|
|
171
|
+
// Must be inside lib/bins/ (or legacy Bins/)
|
|
172
|
+
if (!rel.startsWith(BINS_DIR + '/') && !rel.toLowerCase().startsWith('bins/')) return null;
|
|
173
|
+
|
|
174
|
+
const ext = extname(filePath).replace('.', '').toLowerCase();
|
|
175
|
+
if (!ext) return null;
|
|
176
|
+
|
|
177
|
+
const isContent = CONTENT_EXTENSIONS.has(ext);
|
|
178
|
+
const isMedia = MEDIA_EXTENSIONS.has(ext);
|
|
179
|
+
if (!isContent && !isMedia) return null;
|
|
180
|
+
|
|
181
|
+
const appConfig = await loadAppConfig();
|
|
182
|
+
const structure = await loadStructureFile();
|
|
183
|
+
|
|
184
|
+
// Resolve BinID from the file's parent directory
|
|
185
|
+
const fileDir = dirname(rel);
|
|
186
|
+
const bin = findBinByPath(fileDir, structure);
|
|
187
|
+
|
|
188
|
+
const fileName = basename(filePath);
|
|
189
|
+
const base = basename(filePath, extname(filePath));
|
|
190
|
+
|
|
191
|
+
// Build metadata path next to the file
|
|
192
|
+
const metaPath = join(dirname(filePath), `${base}.metadata.json`);
|
|
193
|
+
|
|
194
|
+
// Use the bin's server-side Path field (e.g. "assets/css", "assets/gfx")
|
|
195
|
+
const binPath = bin?.path || '';
|
|
196
|
+
|
|
197
|
+
if (isContent) {
|
|
198
|
+
// content.Name = filename without extension, content.Path = <binPath>/<Name>.<Extension>
|
|
199
|
+
const contentPath = binPath
|
|
200
|
+
? `${binPath}/${base}.${ext.toLowerCase()}`
|
|
201
|
+
: `${base}.${ext.toLowerCase()}`;
|
|
202
|
+
|
|
203
|
+
const meta = {
|
|
204
|
+
_entity: 'content',
|
|
205
|
+
_contentColumns: ['Content'],
|
|
206
|
+
Name: base,
|
|
207
|
+
Content: `@${fileName}`,
|
|
208
|
+
Extension: ext.toUpperCase(),
|
|
209
|
+
Path: contentPath,
|
|
210
|
+
Active: 1,
|
|
211
|
+
Public: 0,
|
|
212
|
+
};
|
|
213
|
+
if (bin) meta.BinID = bin.binId;
|
|
214
|
+
if (appConfig.AppID) meta.AppID = appConfig.AppID;
|
|
215
|
+
|
|
216
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
217
|
+
log.success(`Auto-created content metadata for "${fileName}"`);
|
|
218
|
+
|
|
219
|
+
// Seed metadata_templates.json with content fields if missing
|
|
220
|
+
await seedMetadataTemplate();
|
|
221
|
+
|
|
222
|
+
return { meta, metaPath };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Media: media.Name = filename without extension, media.Filename = filename with extension
|
|
226
|
+
// media.Path = bin directory path, media.FullPath = /media/<AppShortName>/app/<binPath>/<Filename>
|
|
227
|
+
const meta = {
|
|
228
|
+
_entity: 'media',
|
|
229
|
+
_mediaFile: `@${fileName}`,
|
|
230
|
+
Name: base,
|
|
231
|
+
Filename: fileName,
|
|
232
|
+
Extension: ext,
|
|
233
|
+
Ownership: 'App',
|
|
234
|
+
};
|
|
235
|
+
if (bin) meta.BinID = bin.binId;
|
|
236
|
+
if (appConfig.AppID) meta.AppID = appConfig.AppID;
|
|
237
|
+
if (binPath) meta.Path = binPath;
|
|
238
|
+
|
|
239
|
+
const mimeType = MIME_TYPES[ext];
|
|
240
|
+
if (mimeType) meta.MimeType = mimeType;
|
|
241
|
+
|
|
242
|
+
// Build FullPath: /media/<AppShortName>/app/<binPath>/<Filename>
|
|
243
|
+
if (appConfig.AppShortName) {
|
|
244
|
+
const fullPathParts = ['', 'media', appConfig.AppShortName, 'app'];
|
|
245
|
+
if (binPath) fullPathParts.push(binPath);
|
|
246
|
+
fullPathParts.push(fileName);
|
|
247
|
+
meta.FullPath = fullPathParts.join('/');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
251
|
+
log.success(`Auto-created media metadata for "${fileName}"`);
|
|
252
|
+
|
|
253
|
+
// Seed metadata_templates.json with media fields if missing
|
|
254
|
+
await seedMetadataTemplate();
|
|
255
|
+
|
|
256
|
+
return { meta, metaPath };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Default template cols for core entity types */
|
|
260
|
+
const TEMPLATE_DEFAULTS = {
|
|
261
|
+
content: [
|
|
262
|
+
'AppID', 'BinID', 'Name', 'Path', 'Content=@reference', 'Extension',
|
|
263
|
+
'Type', 'Title', 'Active', 'Public=0', 'SiteID', 'Description',
|
|
264
|
+
'Keywords', 'Parameters', 'CacheControl', 'RequireSSL', 'EntityID',
|
|
265
|
+
],
|
|
266
|
+
media: [
|
|
267
|
+
'AppID', 'BinID', 'Name', 'Path', 'Filename', 'Extension',
|
|
268
|
+
'MimeType', 'FileSize', 'Ownership', 'Public', 'Description',
|
|
269
|
+
'Duration', 'ParentMediaID',
|
|
270
|
+
],
|
|
271
|
+
output: [
|
|
272
|
+
'AppID', 'BinID', 'Name', 'Type', 'CustomSQL=@reference',
|
|
273
|
+
'Active', 'Public', 'Title', 'Description', 'Parameters',
|
|
274
|
+
'Cache', 'Limit', 'MaxRows', 'RowsPerPage', 'ApplyLocks',
|
|
275
|
+
'AdhocFilters', 'BaseEntityID', 'DataSourceID',
|
|
276
|
+
'DefaultMediaID', 'TemplateContentID', 'children',
|
|
277
|
+
],
|
|
278
|
+
output_value: [
|
|
279
|
+
'OutputID', 'Title', 'ShortName', 'OutputType', 'OrderNumber',
|
|
280
|
+
'CustomSQL', 'Format', 'Aggregate', 'DatePart', 'Distinct',
|
|
281
|
+
'Hide', 'Shared', 'Summary', 'SortType', 'SortOrder',
|
|
282
|
+
'EnforceSecurity', 'ReferencedEntityColumnID',
|
|
283
|
+
'OutputValueEntityColumnRelID', 'children',
|
|
284
|
+
],
|
|
285
|
+
output_value_filter: [
|
|
286
|
+
'OutputID', 'OutputValueID', 'ShortName', 'FilterType', 'Operator',
|
|
287
|
+
'OrderNumber', 'CustomSQL', 'Prompted', 'Required', 'Inclusive',
|
|
288
|
+
'PrimaryValue', 'SecondaryValue', 'FilterPreset',
|
|
289
|
+
'ApplyToAggregate', 'ValuesByOutputID', 'children',
|
|
290
|
+
],
|
|
291
|
+
output_value_entity_column_rel: [
|
|
292
|
+
'OutputID', 'OutputValueID', 'JoinType', 'OrderNumber', 'CustomSQL',
|
|
293
|
+
'Alias', 'SourceJoinID', 'SourceEntityColumnID',
|
|
294
|
+
'ReferencedEntityColumnID', 'children',
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Seed .dbo/metadata_templates.json with default fields for the given entity
|
|
300
|
+
* (and any other missing core entities) if an entry doesn't already exist.
|
|
301
|
+
*/
|
|
302
|
+
async function seedMetadataTemplate() {
|
|
303
|
+
const templates = await loadMetadataTemplates() ?? {};
|
|
304
|
+
|
|
305
|
+
// Seed all missing core entities in one pass
|
|
306
|
+
const seeded = [];
|
|
307
|
+
for (const [name, cols] of Object.entries(TEMPLATE_DEFAULTS)) {
|
|
308
|
+
if (templates[name]) continue;
|
|
309
|
+
templates[name] = cols;
|
|
310
|
+
seeded.push(name);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (seeded.length === 0) return;
|
|
314
|
+
|
|
315
|
+
await saveMetadataTemplates(templates);
|
|
316
|
+
log.dim(` Seeded metadata_templates.json with ${seeded.join(', ')} fields`);
|
|
317
|
+
}
|
|
318
|
+
|
|
121
319
|
async function addSingleFile(filePath, client, options, batchDefaults) {
|
|
122
320
|
// Check .dboignore before doing any processing
|
|
123
321
|
const ig = await loadIgnore();
|
|
@@ -235,6 +433,12 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
|
|
|
235
433
|
return await submitAdd(manifestMeta.meta, manifestMeta.metaPath, filePath, client, options);
|
|
236
434
|
}
|
|
237
435
|
|
|
436
|
+
// Step 3e: Auto-detect content/media files in bin directories (lib/bins/)
|
|
437
|
+
const binMeta = await detectBinFile(filePath);
|
|
438
|
+
if (binMeta) {
|
|
439
|
+
return await submitAdd(binMeta.meta, binMeta.metaPath, filePath, client, options);
|
|
440
|
+
}
|
|
441
|
+
|
|
238
442
|
// Step 4: No usable metadata — interactive wizard
|
|
239
443
|
const inquirer = (await import('inquirer')).default;
|
|
240
444
|
|
|
@@ -363,7 +567,7 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
|
|
|
363
567
|
/**
|
|
364
568
|
* Submit an insert to the server from a metadata object.
|
|
365
569
|
*/
|
|
366
|
-
async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
570
|
+
export async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
367
571
|
const entity = meta._entity;
|
|
368
572
|
const contentCols = new Set(meta._contentColumns || []);
|
|
369
573
|
const metaDir = dirname(metaPath);
|
|
@@ -442,11 +646,22 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
|
442
646
|
throw new Error('Add failed');
|
|
443
647
|
}
|
|
444
648
|
|
|
445
|
-
// Extract UID
|
|
649
|
+
// Extract UID and server-populated fields from response, merge into metadata
|
|
446
650
|
const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
|
|
447
651
|
if (addResults.length > 0) {
|
|
448
|
-
const
|
|
449
|
-
const
|
|
652
|
+
const serverRecord = addResults[0];
|
|
653
|
+
const returnedUID = serverRecord.UID;
|
|
654
|
+
const returnedLastUpdated = serverRecord._LastUpdated;
|
|
655
|
+
|
|
656
|
+
// Merge server-populated fields into meta (e.g. _id, ContentID, _LastUpdated, _CreatedOn)
|
|
657
|
+
for (const [key, value] of Object.entries(serverRecord)) {
|
|
658
|
+
// Skip internal/transient fields and arrays (Physical, Errors)
|
|
659
|
+
if (Array.isArray(value)) continue;
|
|
660
|
+
if (key === 'entity' || key === 'RowID') continue;
|
|
661
|
+
// Don't overwrite existing content/media references or private fields we manage
|
|
662
|
+
if (meta[key] !== undefined && !key.startsWith('_')) continue;
|
|
663
|
+
meta[key] = value;
|
|
664
|
+
}
|
|
450
665
|
|
|
451
666
|
if (returnedUID) {
|
|
452
667
|
// Check if UID is already embedded in the filename (idempotency guard)
|
|
@@ -456,54 +671,43 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
|
456
671
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
457
672
|
log.success(`UID already in filename: ${returnedUID}`);
|
|
458
673
|
} else {
|
|
459
|
-
//
|
|
674
|
+
// Companion file NOT renamed — only update UID in metadata and rename metadata file
|
|
460
675
|
const metaDir = dirname(metaPath);
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
const newBase = buildUidFilename(currentBase, returnedUID);
|
|
464
|
-
const newFilename = currentExt ? `${newBase}.${currentExt}` : newBase;
|
|
465
|
-
const newFilePath = join(metaDir, newFilename);
|
|
466
|
-
const newMetaPath = join(metaDir, `${newBase}.metadata.json`);
|
|
467
|
-
|
|
468
|
-
// Rename content file
|
|
469
|
-
try {
|
|
470
|
-
await rename(filePath, newFilePath);
|
|
471
|
-
log.success(`Renamed: ${basename(filePath)} → ${newFilename}`);
|
|
472
|
-
} catch (err) {
|
|
473
|
-
log.warn(` Could not rename content file: ${err.message}`);
|
|
474
|
-
}
|
|
676
|
+
const metaBase = basename(metaPath, '.metadata.json');
|
|
677
|
+
const newMetaPath = join(metaDir, buildMetaFilename(metaBase, returnedUID));
|
|
475
678
|
|
|
476
|
-
// Update @reference in metadata
|
|
477
679
|
meta.UID = returnedUID;
|
|
478
|
-
for (const col of (meta._contentColumns || [])) {
|
|
479
|
-
const ref = meta[col];
|
|
480
|
-
if (ref && String(ref).startsWith('@')) {
|
|
481
|
-
const oldRefFile = String(ref).substring(1);
|
|
482
|
-
if (oldRefFile === basename(filePath)) {
|
|
483
|
-
meta[col] = `@${newFilename}`;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
680
|
|
|
488
|
-
// Write and rename metadata file
|
|
489
681
|
await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
490
682
|
if (metaPath !== newMetaPath) {
|
|
491
|
-
try { await
|
|
683
|
+
try { await unlink(metaPath); } catch { /* old file already gone */ }
|
|
492
684
|
}
|
|
493
685
|
log.dim(` Renamed metadata: ${basename(metaPath)} → ${basename(newMetaPath)}`);
|
|
686
|
+
log.success(`UID assigned: ${returnedUID}`);
|
|
494
687
|
|
|
495
|
-
// Restore timestamps
|
|
688
|
+
// Restore timestamps for metadata and companion files
|
|
496
689
|
const config = await loadConfig();
|
|
497
690
|
const serverTz = config.ServerTimezone;
|
|
498
691
|
if (serverTz && returnedLastUpdated) {
|
|
499
692
|
try {
|
|
500
|
-
await setFileTimestamps(newFilePath, returnedLastUpdated, returnedLastUpdated, serverTz);
|
|
501
693
|
await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
|
|
694
|
+
for (const col of (meta._contentColumns || [])) {
|
|
695
|
+
const ref = meta[col];
|
|
696
|
+
if (ref && String(ref).startsWith('@')) {
|
|
697
|
+
const fp = join(metaDir, String(ref).substring(1));
|
|
698
|
+
await upsertDeployEntry(fp, returnedUID, entity, col);
|
|
699
|
+
try { await setFileTimestamps(fp, returnedLastUpdated, returnedLastUpdated, serverTz); } catch {}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
703
|
+
const mediaFp = join(metaDir, String(meta._mediaFile).substring(1));
|
|
704
|
+
await upsertDeployEntry(mediaFp, returnedUID, entity, 'File');
|
|
705
|
+
}
|
|
502
706
|
} catch { /* non-critical */ }
|
|
503
707
|
}
|
|
504
708
|
|
|
505
|
-
|
|
506
|
-
|
|
709
|
+
// Update .app_baseline.json so subsequent pull/diff recognize this record
|
|
710
|
+
await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
|
|
507
711
|
}
|
|
508
712
|
log.dim(` Run "dbo pull -e ${entity} ${returnedUID}" to populate all columns`);
|
|
509
713
|
}
|
|
@@ -512,6 +716,30 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
|
512
716
|
return { entity: meta._entity };
|
|
513
717
|
}
|
|
514
718
|
|
|
719
|
+
async function updateBaselineWithNewRecord(meta, uid, lastUpdated) {
|
|
720
|
+
try {
|
|
721
|
+
const baseline = await loadAppJsonBaseline();
|
|
722
|
+
if (!baseline) return;
|
|
723
|
+
const entity = meta._entity;
|
|
724
|
+
if (!entity) return;
|
|
725
|
+
if (!baseline.children) baseline.children = {};
|
|
726
|
+
if (!baseline.children[entity]) baseline.children[entity] = [];
|
|
727
|
+
|
|
728
|
+
const arr = baseline.children[entity];
|
|
729
|
+
const existingIdx = arr.findIndex(r => r.UID === uid || (meta._id && r._id === meta._id));
|
|
730
|
+
const entry = { ...meta, UID: uid };
|
|
731
|
+
if (lastUpdated) entry._LastUpdated = lastUpdated;
|
|
732
|
+
|
|
733
|
+
if (existingIdx >= 0) {
|
|
734
|
+
arr[existingIdx] = { ...arr[existingIdx], ...entry };
|
|
735
|
+
} else {
|
|
736
|
+
arr.push(entry);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
await saveAppJsonBaseline(baseline);
|
|
740
|
+
} catch { /* baseline update is non-critical */ }
|
|
741
|
+
}
|
|
742
|
+
|
|
515
743
|
/**
|
|
516
744
|
* Scan a directory for un-added files and add them.
|
|
517
745
|
*/
|
|
@@ -579,54 +807,135 @@ async function addDirectory(dirPath, client, options) {
|
|
|
579
807
|
|
|
580
808
|
/**
|
|
581
809
|
* Recursively find files that are not yet added to the server.
|
|
582
|
-
* A file is "un-added" if
|
|
583
|
-
*
|
|
584
|
-
*
|
|
810
|
+
* A file is "un-added" if no *.metadata.json references it via @.
|
|
811
|
+
*
|
|
812
|
+
* Detection uses two layers:
|
|
813
|
+
* 1. Per-directory: scan sibling *.metadata.json files (handles ~UID naming)
|
|
814
|
+
* 2. Project-wide: buildReferencedFileSet catches cross-directory @/ references
|
|
585
815
|
*/
|
|
586
|
-
async function findUnaddedFiles(dir, ig) {
|
|
816
|
+
export async function findUnaddedFiles(dir, ig, referencedFiles) {
|
|
587
817
|
if (!ig) ig = await loadIgnore();
|
|
588
818
|
|
|
819
|
+
// On first call, build the set of files referenced by existing metadata
|
|
820
|
+
if (!referencedFiles) {
|
|
821
|
+
referencedFiles = await buildReferencedFileSet();
|
|
822
|
+
}
|
|
823
|
+
|
|
589
824
|
const results = [];
|
|
590
825
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
591
826
|
|
|
827
|
+
// Build a set of filenames referenced by sibling metadata in this directory.
|
|
828
|
+
// This handles the ~UID naming convention: colors.metadata~uid.json → @colors.css
|
|
829
|
+
const localRefs = new Set();
|
|
830
|
+
for (const entry of entries) {
|
|
831
|
+
if (!isMetadataFile(entry.name)) continue;
|
|
832
|
+
try {
|
|
833
|
+
const raw = await readFile(join(dir, entry.name), 'utf8');
|
|
834
|
+
if (!raw.trim()) continue;
|
|
835
|
+
const meta = JSON.parse(raw);
|
|
836
|
+
// Only count records that are on the server (have UID or _CreatedOn)
|
|
837
|
+
if (!meta.UID && !meta._CreatedOn) continue;
|
|
838
|
+
for (const col of (meta._contentColumns || [])) {
|
|
839
|
+
const val = meta[col];
|
|
840
|
+
if (typeof val === 'string' && val.startsWith('@') && !val.startsWith('@/')) {
|
|
841
|
+
localRefs.add(val.substring(1));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (meta._mediaFile && typeof meta._mediaFile === 'string' && meta._mediaFile.startsWith('@')) {
|
|
845
|
+
localRefs.add(meta._mediaFile.substring(1));
|
|
846
|
+
}
|
|
847
|
+
} catch { /* skip unreadable */ }
|
|
848
|
+
}
|
|
849
|
+
|
|
592
850
|
for (const entry of entries) {
|
|
593
851
|
const fullPath = join(dir, entry.name);
|
|
594
852
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
595
853
|
|
|
596
854
|
if (entry.isDirectory()) {
|
|
597
855
|
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
598
|
-
results.push(...await findUnaddedFiles(fullPath, ig));
|
|
856
|
+
results.push(...await findUnaddedFiles(fullPath, ig, referencedFiles));
|
|
599
857
|
continue;
|
|
600
858
|
}
|
|
601
859
|
|
|
602
|
-
// Skip metadata files themselves
|
|
603
|
-
if (entry.name
|
|
860
|
+
// Skip metadata files themselves
|
|
861
|
+
if (isMetadataFile(entry.name)) continue;
|
|
604
862
|
// Skip ignored files
|
|
605
863
|
if (ig.ignores(relPath)) continue;
|
|
606
864
|
|
|
607
|
-
// Check
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
865
|
+
// Check 1: sibling metadata references this file via @
|
|
866
|
+
if (localRefs.has(entry.name)) continue;
|
|
867
|
+
|
|
868
|
+
// Check 2: project-wide cross-directory @/ references
|
|
869
|
+
if (referencedFiles.has(relPath)) continue;
|
|
611
870
|
|
|
612
|
-
|
|
613
|
-
|
|
871
|
+
results.push(fullPath);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return results;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Scan all metadata files and build a set of content files they reference.
|
|
879
|
+
* This catches files like docs/Chat.md whose metadata lives elsewhere
|
|
880
|
+
* (e.g. lib/extension/Documentation/Chat~uid.metadata.json with "@/docs/Chat.md").
|
|
881
|
+
*/
|
|
882
|
+
async function buildReferencedFileSet() {
|
|
883
|
+
const referenced = new Set();
|
|
884
|
+
await _scanMetadataRefs(process.cwd(), referenced);
|
|
885
|
+
return referenced;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function _scanMetadataRefs(dir, referenced) {
|
|
889
|
+
const ig = await loadIgnore();
|
|
890
|
+
let entries;
|
|
891
|
+
try {
|
|
892
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
893
|
+
} catch {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (const entry of entries) {
|
|
898
|
+
const fullPath = join(dir, entry.name);
|
|
899
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
900
|
+
|
|
901
|
+
if (entry.isDirectory()) {
|
|
902
|
+
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
903
|
+
await _scanMetadataRefs(fullPath, referenced);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (!isMetadataFile(entry.name)) continue;
|
|
614
908
|
|
|
615
909
|
try {
|
|
616
|
-
const raw = await readFile(
|
|
617
|
-
if (raw.trim())
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
910
|
+
const raw = await readFile(fullPath, 'utf8');
|
|
911
|
+
if (!raw.trim()) continue;
|
|
912
|
+
const meta = JSON.parse(raw);
|
|
913
|
+
if (!meta._CreatedOn && !meta.UID) continue; // only count server-confirmed records
|
|
914
|
+
|
|
915
|
+
for (const col of (meta._contentColumns || [])) {
|
|
916
|
+
const val = meta[col];
|
|
917
|
+
if (typeof val === 'string' && val.startsWith('@')) {
|
|
918
|
+
const ref = val.substring(1);
|
|
919
|
+
if (ref.startsWith('/')) {
|
|
920
|
+
// @/path — absolute from project root
|
|
921
|
+
referenced.add(ref.substring(1));
|
|
922
|
+
} else {
|
|
923
|
+
// @filename — relative to metadata file's directory
|
|
924
|
+
const metaDir = relative(process.cwd(), dir).replace(/\\/g, '/');
|
|
925
|
+
const resolved = metaDir ? `${metaDir}/${ref}` : ref;
|
|
926
|
+
referenced.add(resolved);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
621
929
|
}
|
|
622
|
-
} catch {
|
|
623
|
-
// No metadata file
|
|
624
|
-
}
|
|
625
930
|
|
|
626
|
-
|
|
627
|
-
|
|
931
|
+
// Content records with a Path field may have their file at the project root
|
|
932
|
+
// (e.g. manifest.json is cloned to root but metadata lives in lib/bins/app/)
|
|
933
|
+
if (meta._entity === 'content' && meta.Path) {
|
|
934
|
+
const p = String(meta.Path).replace(/^\//, '');
|
|
935
|
+
referenced.add(p);
|
|
936
|
+
}
|
|
937
|
+
} catch {
|
|
938
|
+
// skip unreadable metadata
|
|
628
939
|
}
|
|
629
940
|
}
|
|
630
|
-
|
|
631
|
-
return results;
|
|
632
941
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/commands/build.js
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { relative, basename } from 'path';
|
|
4
|
+
import { loadScripts, loadScriptsLocal, loadConfig, loadAppConfig } from '../lib/config.js';
|
|
5
|
+
import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle } from '../lib/scripts.js';
|
|
6
|
+
import { log } from '../lib/logger.js';
|
|
7
|
+
|
|
8
|
+
export const buildCommand = new Command('build')
|
|
9
|
+
.description('Run the build lifecycle (prebuild → build → postbuild) without pushing')
|
|
10
|
+
.argument('[path]', 'File or directory to build (default: all targets with a build hook)')
|
|
11
|
+
.option('--entity <type>', 'Run builds for all targets matching this entity type')
|
|
12
|
+
.action(async (targetPath, options) => {
|
|
13
|
+
try {
|
|
14
|
+
const base = await loadScripts();
|
|
15
|
+
const local = await loadScriptsLocal();
|
|
16
|
+
|
|
17
|
+
if (!base && !local) {
|
|
18
|
+
if (targetPath) {
|
|
19
|
+
log.warn('No .dbo/scripts.json found — nothing to build');
|
|
20
|
+
} else {
|
|
21
|
+
log.info('No .dbo/scripts.json found');
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = mergeScriptsConfig(base, local);
|
|
27
|
+
const cfg = await loadConfig();
|
|
28
|
+
const app = await loadAppConfig();
|
|
29
|
+
const appConfig = { ...app, domain: cfg.domain };
|
|
30
|
+
|
|
31
|
+
let targets = [];
|
|
32
|
+
|
|
33
|
+
if (targetPath) {
|
|
34
|
+
// Single explicit path
|
|
35
|
+
targets = [targetPath];
|
|
36
|
+
} else if (options.entity) {
|
|
37
|
+
// All targets in scripts.json that match the entity type
|
|
38
|
+
targets = Object.keys(config.targets).filter(t => {
|
|
39
|
+
const hooks = config.targets[t];
|
|
40
|
+
return hooks && hooks.build !== undefined;
|
|
41
|
+
});
|
|
42
|
+
if (targets.length === 0) {
|
|
43
|
+
log.info(`No targets with a build hook found for entity type "${options.entity}"`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
// All targets in scripts.json that have a build hook
|
|
48
|
+
targets = Object.keys(config.targets).filter(t => {
|
|
49
|
+
const hooks = config.targets[t];
|
|
50
|
+
return hooks && hooks.build !== undefined;
|
|
51
|
+
});
|
|
52
|
+
// Also check global scripts for a build hook (runs once, not per-target)
|
|
53
|
+
if (config.scripts && config.scripts.build !== undefined) {
|
|
54
|
+
// Run global build once
|
|
55
|
+
const env = buildHookEnv('', '', appConfig);
|
|
56
|
+
const hooks = { prebuild: config.scripts.prebuild, build: config.scripts.build, postbuild: config.scripts.postbuild };
|
|
57
|
+
log.info('Running global build hooks...');
|
|
58
|
+
const ok = await runBuildLifecycle(hooks, env, process.cwd());
|
|
59
|
+
if (!ok) {
|
|
60
|
+
log.error('Global build hook failed');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (targets.length === 0) return; // only global, done
|
|
64
|
+
}
|
|
65
|
+
if (targets.length === 0) {
|
|
66
|
+
log.info('No build hooks defined in .dbo/scripts.json targets');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let failed = 0;
|
|
72
|
+
for (const t of targets) {
|
|
73
|
+
const relPath = t.replace(/\\/g, '/');
|
|
74
|
+
// Determine entity type from option or empty string
|
|
75
|
+
let entityType = '';
|
|
76
|
+
if (options.entity) entityType = options.entity;
|
|
77
|
+
|
|
78
|
+
const hooks = resolveHooks(relPath, entityType, config);
|
|
79
|
+
if (hooks.build === undefined && hooks.prebuild === undefined && hooks.postbuild === undefined) {
|
|
80
|
+
log.dim(` No build hooks for ${relPath} — skipping`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log.info(`Building: ${relPath}`);
|
|
85
|
+
const env = buildHookEnv(relPath, entityType, appConfig);
|
|
86
|
+
const ok = await runBuildLifecycle(hooks, env, process.cwd());
|
|
87
|
+
if (!ok) {
|
|
88
|
+
log.error(`Build failed for "${relPath}"`);
|
|
89
|
+
failed++;
|
|
90
|
+
break; // fail-fast: first failure stops execution
|
|
91
|
+
}
|
|
92
|
+
log.success(` Built: ${relPath}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (failed > 0) {
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log.error(err.message);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
});
|