@dboio/cli 0.11.3 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -3
- package/bin/dbo.js +4 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +65 -244
- 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 +2222 -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 +62 -246
- package/src/commands/add.js +366 -62
- package/src/commands/build.js +102 -0
- package/src/commands/clone.js +602 -139
- package/src/commands/diff.js +4 -0
- package/src/commands/init.js +16 -2
- package/src/commands/input.js +3 -1
- package/src/commands/mv.js +12 -4
- package/src/commands/push.js +265 -70
- package/src/commands/rm.js +16 -3
- package/src/commands/run.js +81 -0
- package/src/lib/client.js +4 -7
- package/src/lib/config.js +39 -0
- package/src/lib/delta.js +7 -1
- package/src/lib/diff.js +24 -2
- package/src/lib/filenames.js +120 -41
- package/src/lib/ignore.js +6 -0
- package/src/lib/input-parser.js +13 -4
- package/src/lib/scripts.js +232 -0
- package/src/lib/toe-stepping.js +17 -2
- package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
package/src/commands/add.js
CHANGED
|
@@ -1,21 +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
11
|
import { buildUidFilename, hasUidInFilename } 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
18
|
|
|
19
|
+
// ─── File type classification for auto-detection in bin directories ────────
|
|
20
|
+
|
|
21
|
+
/** Extensions treated as content entities (text-based, stored in content.Content) */
|
|
22
|
+
const CONTENT_EXTENSIONS = new Set([
|
|
23
|
+
'sh', 'js', 'json', 'html', 'txt', 'xml', 'css', 'md',
|
|
24
|
+
'htm', 'svg', 'sql', 'csv', 'yaml', 'yml', 'ts', 'jsx', 'tsx',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** Extensions treated as media entities (binary files: images, audio, video) */
|
|
28
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
29
|
+
// Images
|
|
30
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif',
|
|
31
|
+
// Audio
|
|
32
|
+
'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
|
|
33
|
+
// Video
|
|
34
|
+
'mp4', 'webm', 'mov', 'avi', 'mkv', 'wmv', 'flv',
|
|
35
|
+
// Fonts
|
|
36
|
+
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
|
37
|
+
// Documents / archives (binary)
|
|
38
|
+
'pdf', 'zip', 'gz', 'tar',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Simple MIME type lookup for common media extensions */
|
|
42
|
+
const MIME_TYPES = {
|
|
43
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
44
|
+
webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', svg: 'image/svg+xml',
|
|
45
|
+
tiff: 'image/tiff', tif: 'image/tiff', avif: 'image/avif',
|
|
46
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', aac: 'audio/aac',
|
|
47
|
+
flac: 'audio/flac', m4a: 'audio/mp4', wma: 'audio/x-ms-wma',
|
|
48
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|
49
|
+
avi: 'video/x-msvideo', mkv: 'video/x-matroska', wmv: 'video/x-ms-wmv',
|
|
50
|
+
flv: 'video/x-flv',
|
|
51
|
+
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
|
|
52
|
+
eot: 'application/vnd.ms-fontobject',
|
|
53
|
+
pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
|
|
54
|
+
tar: 'application/x-tar',
|
|
55
|
+
};
|
|
56
|
+
|
|
19
57
|
export const addCommand = new Command('add')
|
|
20
58
|
.description('Add a new file to DBO.io (creates record on server)')
|
|
21
59
|
.argument('<path>', 'File or "." to scan current directory')
|
|
@@ -118,6 +156,165 @@ async function detectManifestFile(filePath) {
|
|
|
118
156
|
return { meta, metaPath };
|
|
119
157
|
}
|
|
120
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Detect whether a file lives inside lib/bins/ and auto-classify as content or media.
|
|
161
|
+
*
|
|
162
|
+
* Content extensions: sh, js, json, html, txt, xml, css, md, etc.
|
|
163
|
+
* Media extensions: png, jpg, webp, mp4, woff2, pdf, etc.
|
|
164
|
+
*
|
|
165
|
+
* Returns { meta, metaPath } or null if not applicable.
|
|
166
|
+
*/
|
|
167
|
+
export async function detectBinFile(filePath) {
|
|
168
|
+
const rel = relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
169
|
+
|
|
170
|
+
// Must be inside lib/bins/ (or legacy Bins/)
|
|
171
|
+
if (!rel.startsWith(BINS_DIR + '/') && !rel.toLowerCase().startsWith('bins/')) return null;
|
|
172
|
+
|
|
173
|
+
const ext = extname(filePath).replace('.', '').toLowerCase();
|
|
174
|
+
if (!ext) return null;
|
|
175
|
+
|
|
176
|
+
const isContent = CONTENT_EXTENSIONS.has(ext);
|
|
177
|
+
const isMedia = MEDIA_EXTENSIONS.has(ext);
|
|
178
|
+
if (!isContent && !isMedia) return null;
|
|
179
|
+
|
|
180
|
+
const appConfig = await loadAppConfig();
|
|
181
|
+
const structure = await loadStructureFile();
|
|
182
|
+
|
|
183
|
+
// Resolve BinID from the file's parent directory
|
|
184
|
+
const fileDir = dirname(rel);
|
|
185
|
+
const bin = findBinByPath(fileDir, structure);
|
|
186
|
+
|
|
187
|
+
const fileName = basename(filePath);
|
|
188
|
+
const base = basename(filePath, extname(filePath));
|
|
189
|
+
|
|
190
|
+
// Build metadata path next to the file
|
|
191
|
+
const metaPath = join(dirname(filePath), `${base}.metadata.json`);
|
|
192
|
+
|
|
193
|
+
// Use the bin's server-side Path field (e.g. "assets/css", "assets/gfx")
|
|
194
|
+
const binPath = bin?.path || '';
|
|
195
|
+
|
|
196
|
+
if (isContent) {
|
|
197
|
+
// content.Name = filename without extension, content.Path = <binPath>/<Name>.<Extension>
|
|
198
|
+
const contentPath = binPath
|
|
199
|
+
? `${binPath}/${base}.${ext.toLowerCase()}`
|
|
200
|
+
: `${base}.${ext.toLowerCase()}`;
|
|
201
|
+
|
|
202
|
+
const meta = {
|
|
203
|
+
_entity: 'content',
|
|
204
|
+
_contentColumns: ['Content'],
|
|
205
|
+
Name: base,
|
|
206
|
+
Content: `@${fileName}`,
|
|
207
|
+
Extension: ext.toUpperCase(),
|
|
208
|
+
Path: contentPath,
|
|
209
|
+
Active: 1,
|
|
210
|
+
Public: 0,
|
|
211
|
+
};
|
|
212
|
+
if (bin) meta.BinID = bin.binId;
|
|
213
|
+
if (appConfig.AppID) meta.AppID = appConfig.AppID;
|
|
214
|
+
|
|
215
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
216
|
+
log.success(`Auto-created content metadata for "${fileName}"`);
|
|
217
|
+
|
|
218
|
+
// Seed metadata_templates.json with content fields if missing
|
|
219
|
+
await seedMetadataTemplate();
|
|
220
|
+
|
|
221
|
+
return { meta, metaPath };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Media: media.Name = filename without extension, media.Filename = filename with extension
|
|
225
|
+
// media.Path = bin directory path, media.FullPath = /media/<AppShortName>/app/<binPath>/<Filename>
|
|
226
|
+
const meta = {
|
|
227
|
+
_entity: 'media',
|
|
228
|
+
_mediaFile: `@${fileName}`,
|
|
229
|
+
Name: base,
|
|
230
|
+
Filename: fileName,
|
|
231
|
+
Extension: ext,
|
|
232
|
+
Ownership: 'App',
|
|
233
|
+
};
|
|
234
|
+
if (bin) meta.BinID = bin.binId;
|
|
235
|
+
if (appConfig.AppID) meta.AppID = appConfig.AppID;
|
|
236
|
+
if (binPath) meta.Path = binPath;
|
|
237
|
+
|
|
238
|
+
const mimeType = MIME_TYPES[ext];
|
|
239
|
+
if (mimeType) meta.MimeType = mimeType;
|
|
240
|
+
|
|
241
|
+
// Build FullPath: /media/<AppShortName>/app/<binPath>/<Filename>
|
|
242
|
+
if (appConfig.AppShortName) {
|
|
243
|
+
const fullPathParts = ['', 'media', appConfig.AppShortName, 'app'];
|
|
244
|
+
if (binPath) fullPathParts.push(binPath);
|
|
245
|
+
fullPathParts.push(fileName);
|
|
246
|
+
meta.FullPath = fullPathParts.join('/');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
250
|
+
log.success(`Auto-created media metadata for "${fileName}"`);
|
|
251
|
+
|
|
252
|
+
// Seed metadata_templates.json with media fields if missing
|
|
253
|
+
await seedMetadataTemplate();
|
|
254
|
+
|
|
255
|
+
return { meta, metaPath };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Default template cols for core entity types */
|
|
259
|
+
const TEMPLATE_DEFAULTS = {
|
|
260
|
+
content: [
|
|
261
|
+
'AppID', 'BinID', 'Name', 'Path', 'Content=@reference', 'Extension',
|
|
262
|
+
'Type', 'Title', 'Active', 'Public=0', 'SiteID', 'Description',
|
|
263
|
+
'Keywords', 'Parameters', 'CacheControl', 'RequireSSL', 'EntityID',
|
|
264
|
+
],
|
|
265
|
+
media: [
|
|
266
|
+
'AppID', 'BinID', 'Name', 'Path', 'Filename', 'Extension',
|
|
267
|
+
'MimeType', 'FileSize', 'Ownership', 'Public', 'Description',
|
|
268
|
+
'Duration', 'ParentMediaID',
|
|
269
|
+
],
|
|
270
|
+
output: [
|
|
271
|
+
'AppID', 'BinID', 'Name', 'Type', 'CustomSQL=@reference',
|
|
272
|
+
'Active', 'Public', 'Title', 'Description', 'Parameters',
|
|
273
|
+
'Cache', 'Limit', 'MaxRows', 'RowsPerPage', 'ApplyLocks',
|
|
274
|
+
'AdhocFilters', 'BaseEntityID', 'DataSourceID',
|
|
275
|
+
'DefaultMediaID', 'TemplateContentID', 'children',
|
|
276
|
+
],
|
|
277
|
+
output_value: [
|
|
278
|
+
'OutputID', 'Title', 'ShortName', 'OutputType', 'OrderNumber',
|
|
279
|
+
'CustomSQL', 'Format', 'Aggregate', 'DatePart', 'Distinct',
|
|
280
|
+
'Hide', 'Shared', 'Summary', 'SortType', 'SortOrder',
|
|
281
|
+
'EnforceSecurity', 'ReferencedEntityColumnID',
|
|
282
|
+
'OutputValueEntityColumnRelID', 'children',
|
|
283
|
+
],
|
|
284
|
+
output_value_filter: [
|
|
285
|
+
'OutputID', 'OutputValueID', 'ShortName', 'FilterType', 'Operator',
|
|
286
|
+
'OrderNumber', 'CustomSQL', 'Prompted', 'Required', 'Inclusive',
|
|
287
|
+
'PrimaryValue', 'SecondaryValue', 'FilterPreset',
|
|
288
|
+
'ApplyToAggregate', 'ValuesByOutputID', 'children',
|
|
289
|
+
],
|
|
290
|
+
output_value_entity_column_rel: [
|
|
291
|
+
'OutputID', 'OutputValueID', 'JoinType', 'OrderNumber', 'CustomSQL',
|
|
292
|
+
'Alias', 'SourceJoinID', 'SourceEntityColumnID',
|
|
293
|
+
'ReferencedEntityColumnID', 'children',
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Seed .dbo/metadata_templates.json with default fields for the given entity
|
|
299
|
+
* (and any other missing core entities) if an entry doesn't already exist.
|
|
300
|
+
*/
|
|
301
|
+
async function seedMetadataTemplate() {
|
|
302
|
+
const templates = await loadMetadataTemplates() ?? {};
|
|
303
|
+
|
|
304
|
+
// Seed all missing core entities in one pass
|
|
305
|
+
const seeded = [];
|
|
306
|
+
for (const [name, cols] of Object.entries(TEMPLATE_DEFAULTS)) {
|
|
307
|
+
if (templates[name]) continue;
|
|
308
|
+
templates[name] = cols;
|
|
309
|
+
seeded.push(name);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (seeded.length === 0) return;
|
|
313
|
+
|
|
314
|
+
await saveMetadataTemplates(templates);
|
|
315
|
+
log.dim(` Seeded metadata_templates.json with ${seeded.join(', ')} fields`);
|
|
316
|
+
}
|
|
317
|
+
|
|
121
318
|
async function addSingleFile(filePath, client, options, batchDefaults) {
|
|
122
319
|
// Check .dboignore before doing any processing
|
|
123
320
|
const ig = await loadIgnore();
|
|
@@ -235,6 +432,12 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
|
|
|
235
432
|
return await submitAdd(manifestMeta.meta, manifestMeta.metaPath, filePath, client, options);
|
|
236
433
|
}
|
|
237
434
|
|
|
435
|
+
// Step 3e: Auto-detect content/media files in bin directories (lib/bins/)
|
|
436
|
+
const binMeta = await detectBinFile(filePath);
|
|
437
|
+
if (binMeta) {
|
|
438
|
+
return await submitAdd(binMeta.meta, binMeta.metaPath, filePath, client, options);
|
|
439
|
+
}
|
|
440
|
+
|
|
238
441
|
// Step 4: No usable metadata — interactive wizard
|
|
239
442
|
const inquirer = (await import('inquirer')).default;
|
|
240
443
|
|
|
@@ -363,7 +566,7 @@ async function addSingleFile(filePath, client, options, batchDefaults) {
|
|
|
363
566
|
/**
|
|
364
567
|
* Submit an insert to the server from a metadata object.
|
|
365
568
|
*/
|
|
366
|
-
async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
569
|
+
export async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
367
570
|
const entity = meta._entity;
|
|
368
571
|
const contentCols = new Set(meta._contentColumns || []);
|
|
369
572
|
const metaDir = dirname(metaPath);
|
|
@@ -442,11 +645,22 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
|
442
645
|
throw new Error('Add failed');
|
|
443
646
|
}
|
|
444
647
|
|
|
445
|
-
// Extract UID
|
|
648
|
+
// Extract UID and server-populated fields from response, merge into metadata
|
|
446
649
|
const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
|
|
447
650
|
if (addResults.length > 0) {
|
|
448
|
-
const
|
|
449
|
-
const
|
|
651
|
+
const serverRecord = addResults[0];
|
|
652
|
+
const returnedUID = serverRecord.UID;
|
|
653
|
+
const returnedLastUpdated = serverRecord._LastUpdated;
|
|
654
|
+
|
|
655
|
+
// Merge server-populated fields into meta (e.g. _id, ContentID, _LastUpdated, _CreatedOn)
|
|
656
|
+
for (const [key, value] of Object.entries(serverRecord)) {
|
|
657
|
+
// Skip internal/transient fields and arrays (Physical, Errors)
|
|
658
|
+
if (Array.isArray(value)) continue;
|
|
659
|
+
if (key === 'entity' || key === 'RowID') continue;
|
|
660
|
+
// Don't overwrite existing content/media references or private fields we manage
|
|
661
|
+
if (meta[key] !== undefined && !key.startsWith('_')) continue;
|
|
662
|
+
meta[key] = value;
|
|
663
|
+
}
|
|
450
664
|
|
|
451
665
|
if (returnedUID) {
|
|
452
666
|
// Check if UID is already embedded in the filename (idempotency guard)
|
|
@@ -456,54 +670,39 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
|
456
670
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
457
671
|
log.success(`UID already in filename: ${returnedUID}`);
|
|
458
672
|
} else {
|
|
459
|
-
//
|
|
673
|
+
// Companion file NOT renamed — only update UID in metadata and rename metadata file
|
|
460
674
|
const metaDir = dirname(metaPath);
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
const
|
|
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
|
-
}
|
|
675
|
+
const metaBase = basename(metaPath, '.metadata.json');
|
|
676
|
+
const newMetaBase = buildUidFilename(metaBase, returnedUID);
|
|
677
|
+
const newMetaPath = join(metaDir, `${newMetaBase}.metadata.json`);
|
|
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
|
+
try { await setFileTimestamps(fp, returnedLastUpdated, returnedLastUpdated, serverTz); } catch {}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
502
701
|
} catch { /* non-critical */ }
|
|
503
702
|
}
|
|
504
703
|
|
|
505
|
-
|
|
506
|
-
|
|
704
|
+
// Update .app_baseline.json so subsequent pull/diff recognize this record
|
|
705
|
+
await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
|
|
507
706
|
}
|
|
508
707
|
log.dim(` Run "dbo pull -e ${entity} ${returnedUID}" to populate all columns`);
|
|
509
708
|
}
|
|
@@ -512,6 +711,30 @@ async function submitAdd(meta, metaPath, filePath, client, options) {
|
|
|
512
711
|
return { entity: meta._entity };
|
|
513
712
|
}
|
|
514
713
|
|
|
714
|
+
async function updateBaselineWithNewRecord(meta, uid, lastUpdated) {
|
|
715
|
+
try {
|
|
716
|
+
const baseline = await loadAppJsonBaseline();
|
|
717
|
+
if (!baseline) return;
|
|
718
|
+
const entity = meta._entity;
|
|
719
|
+
if (!entity) return;
|
|
720
|
+
if (!baseline.children) baseline.children = {};
|
|
721
|
+
if (!baseline.children[entity]) baseline.children[entity] = [];
|
|
722
|
+
|
|
723
|
+
const arr = baseline.children[entity];
|
|
724
|
+
const existingIdx = arr.findIndex(r => r.UID === uid || (meta._id && r._id === meta._id));
|
|
725
|
+
const entry = { ...meta, UID: uid };
|
|
726
|
+
if (lastUpdated) entry._LastUpdated = lastUpdated;
|
|
727
|
+
|
|
728
|
+
if (existingIdx >= 0) {
|
|
729
|
+
arr[existingIdx] = { ...arr[existingIdx], ...entry };
|
|
730
|
+
} else {
|
|
731
|
+
arr.push(entry);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
await saveAppJsonBaseline(baseline);
|
|
735
|
+
} catch { /* baseline update is non-critical */ }
|
|
736
|
+
}
|
|
737
|
+
|
|
515
738
|
/**
|
|
516
739
|
* Scan a directory for un-added files and add them.
|
|
517
740
|
*/
|
|
@@ -579,54 +802,135 @@ async function addDirectory(dirPath, client, options) {
|
|
|
579
802
|
|
|
580
803
|
/**
|
|
581
804
|
* Recursively find files that are not yet added to the server.
|
|
582
|
-
* A file is "un-added" if
|
|
583
|
-
*
|
|
584
|
-
*
|
|
805
|
+
* A file is "un-added" if no *.metadata.json references it via @.
|
|
806
|
+
*
|
|
807
|
+
* Detection uses two layers:
|
|
808
|
+
* 1. Per-directory: scan sibling *.metadata.json files (handles ~UID naming)
|
|
809
|
+
* 2. Project-wide: buildReferencedFileSet catches cross-directory @/ references
|
|
585
810
|
*/
|
|
586
|
-
async function findUnaddedFiles(dir, ig) {
|
|
811
|
+
export async function findUnaddedFiles(dir, ig, referencedFiles) {
|
|
587
812
|
if (!ig) ig = await loadIgnore();
|
|
588
813
|
|
|
814
|
+
// On first call, build the set of files referenced by existing metadata
|
|
815
|
+
if (!referencedFiles) {
|
|
816
|
+
referencedFiles = await buildReferencedFileSet();
|
|
817
|
+
}
|
|
818
|
+
|
|
589
819
|
const results = [];
|
|
590
820
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
591
821
|
|
|
822
|
+
// Build a set of filenames referenced by sibling metadata in this directory.
|
|
823
|
+
// This handles the ~UID naming convention: colors~uid.metadata.json → @colors.css
|
|
824
|
+
const localRefs = new Set();
|
|
825
|
+
for (const entry of entries) {
|
|
826
|
+
if (!entry.name.endsWith('.metadata.json')) continue;
|
|
827
|
+
try {
|
|
828
|
+
const raw = await readFile(join(dir, entry.name), 'utf8');
|
|
829
|
+
if (!raw.trim()) continue;
|
|
830
|
+
const meta = JSON.parse(raw);
|
|
831
|
+
// Only count records that are on the server (have UID or _CreatedOn)
|
|
832
|
+
if (!meta.UID && !meta._CreatedOn) continue;
|
|
833
|
+
for (const col of (meta._contentColumns || [])) {
|
|
834
|
+
const val = meta[col];
|
|
835
|
+
if (typeof val === 'string' && val.startsWith('@') && !val.startsWith('@/')) {
|
|
836
|
+
localRefs.add(val.substring(1));
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (meta._mediaFile && typeof meta._mediaFile === 'string' && meta._mediaFile.startsWith('@')) {
|
|
840
|
+
localRefs.add(meta._mediaFile.substring(1));
|
|
841
|
+
}
|
|
842
|
+
} catch { /* skip unreadable */ }
|
|
843
|
+
}
|
|
844
|
+
|
|
592
845
|
for (const entry of entries) {
|
|
593
846
|
const fullPath = join(dir, entry.name);
|
|
594
847
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
595
848
|
|
|
596
849
|
if (entry.isDirectory()) {
|
|
597
850
|
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
598
|
-
results.push(...await findUnaddedFiles(fullPath, ig));
|
|
851
|
+
results.push(...await findUnaddedFiles(fullPath, ig, referencedFiles));
|
|
599
852
|
continue;
|
|
600
853
|
}
|
|
601
854
|
|
|
602
|
-
// Skip metadata files themselves
|
|
855
|
+
// Skip metadata files themselves
|
|
603
856
|
if (entry.name.endsWith('.metadata.json')) continue;
|
|
604
857
|
// Skip ignored files
|
|
605
858
|
if (ig.ignores(relPath)) continue;
|
|
606
859
|
|
|
607
|
-
// Check
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
860
|
+
// Check 1: sibling metadata references this file via @
|
|
861
|
+
if (localRefs.has(entry.name)) continue;
|
|
862
|
+
|
|
863
|
+
// Check 2: project-wide cross-directory @/ references
|
|
864
|
+
if (referencedFiles.has(relPath)) continue;
|
|
611
865
|
|
|
612
|
-
|
|
613
|
-
|
|
866
|
+
results.push(fullPath);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return results;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Scan all metadata files and build a set of content files they reference.
|
|
874
|
+
* This catches files like docs/Chat.md whose metadata lives elsewhere
|
|
875
|
+
* (e.g. lib/extension/Documentation/Chat~uid.metadata.json with "@/docs/Chat.md").
|
|
876
|
+
*/
|
|
877
|
+
async function buildReferencedFileSet() {
|
|
878
|
+
const referenced = new Set();
|
|
879
|
+
await _scanMetadataRefs(process.cwd(), referenced);
|
|
880
|
+
return referenced;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function _scanMetadataRefs(dir, referenced) {
|
|
884
|
+
const ig = await loadIgnore();
|
|
885
|
+
let entries;
|
|
886
|
+
try {
|
|
887
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
888
|
+
} catch {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
for (const entry of entries) {
|
|
893
|
+
const fullPath = join(dir, entry.name);
|
|
894
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
895
|
+
|
|
896
|
+
if (entry.isDirectory()) {
|
|
897
|
+
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
898
|
+
await _scanMetadataRefs(fullPath, referenced);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (!entry.name.endsWith('.metadata.json')) continue;
|
|
614
903
|
|
|
615
904
|
try {
|
|
616
|
-
const raw = await readFile(
|
|
617
|
-
if (raw.trim())
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
905
|
+
const raw = await readFile(fullPath, 'utf8');
|
|
906
|
+
if (!raw.trim()) continue;
|
|
907
|
+
const meta = JSON.parse(raw);
|
|
908
|
+
if (!meta._CreatedOn && !meta.UID) continue; // only count server-confirmed records
|
|
909
|
+
|
|
910
|
+
for (const col of (meta._contentColumns || [])) {
|
|
911
|
+
const val = meta[col];
|
|
912
|
+
if (typeof val === 'string' && val.startsWith('@')) {
|
|
913
|
+
const ref = val.substring(1);
|
|
914
|
+
if (ref.startsWith('/')) {
|
|
915
|
+
// @/path — absolute from project root
|
|
916
|
+
referenced.add(ref.substring(1));
|
|
917
|
+
} else {
|
|
918
|
+
// @filename — relative to metadata file's directory
|
|
919
|
+
const metaDir = relative(process.cwd(), dir).replace(/\\/g, '/');
|
|
920
|
+
const resolved = metaDir ? `${metaDir}/${ref}` : ref;
|
|
921
|
+
referenced.add(resolved);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
621
924
|
}
|
|
622
|
-
} catch {
|
|
623
|
-
// No metadata file
|
|
624
|
-
}
|
|
625
925
|
|
|
626
|
-
|
|
627
|
-
|
|
926
|
+
// Content records with a Path field may have their file at the project root
|
|
927
|
+
// (e.g. manifest.json is cloned to root but metadata lives in lib/bins/app/)
|
|
928
|
+
if (meta._entity === 'content' && meta.Path) {
|
|
929
|
+
const p = String(meta.Path).replace(/^\//, '');
|
|
930
|
+
referenced.add(p);
|
|
931
|
+
}
|
|
932
|
+
} catch {
|
|
933
|
+
// skip unreadable metadata
|
|
628
934
|
}
|
|
629
935
|
}
|
|
630
|
-
|
|
631
|
-
return results;
|
|
632
936
|
}
|
|
@@ -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
|
+
});
|