@dboio/cli 0.10.1 → 0.11.1
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 +135 -70
- package/bin/postinstall.js +9 -1
- package/package.json +3 -3
- package/plugins/claude/dbo/commands/dbo.md +3 -3
- package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
- package/src/commands/add.js +4 -0
- package/src/commands/clone.js +249 -399
- package/src/commands/content.js +7 -3
- package/src/commands/deploy.js +22 -7
- package/src/commands/diff.js +41 -3
- package/src/commands/init.js +25 -60
- package/src/commands/input.js +5 -0
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +3 -0
- package/src/commands/output.js +8 -10
- package/src/commands/pull.js +12 -8
- package/src/commands/push.js +317 -42
- package/src/commands/rm.js +3 -0
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +3 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +31 -0
- package/src/lib/delta.js +92 -0
- package/src/lib/diff.js +143 -19
- package/src/lib/ignore.js +1 -2
- package/src/lib/metadata-templates.js +21 -4
- package/src/lib/migrations.js +75 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/scaffold.js +3 -28
- package/src/lib/structure.js +158 -23
- package/src/lib/toe-stepping.js +381 -0
- package/src/migrations/001-transaction-key-preset-scope.js +35 -0
- package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
- package/src/migrations/003-move-deploy-config.js +50 -0
- package/src/migrations/004-rename-output-files.js +101 -0
package/src/lib/delta.js
CHANGED
|
@@ -147,6 +147,17 @@ export async function detectChangedColumns(metaPath, baseline) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Check for columns present in baseline but removed from local metadata
|
|
151
|
+
// (user deleted the key or set it to null → should clear on server)
|
|
152
|
+
for (const [columnName, baselineVal] of Object.entries(baselineEntry)) {
|
|
153
|
+
if (shouldSkipColumn(columnName)) continue;
|
|
154
|
+
if (columnName in metadata) continue; // already compared above
|
|
155
|
+
// Baseline has a non-null value but local metadata doesn't have this key at all
|
|
156
|
+
if (baselineVal !== null && baselineVal !== undefined && normalizeValue(baselineVal) !== '') {
|
|
157
|
+
changedColumns.push(columnName);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
// Check _mediaFile for binary file changes (media entities)
|
|
151
162
|
if (metadata._mediaFile && isReference(metadata._mediaFile)) {
|
|
152
163
|
const mediaPath = resolveReferencePath(metadata._mediaFile, metaDir);
|
|
@@ -285,9 +296,90 @@ async function _detectEntityChanges(entity, baseline, metaDir) {
|
|
|
285
296
|
}
|
|
286
297
|
}
|
|
287
298
|
}
|
|
299
|
+
// Check for columns removed from entity but present in baseline
|
|
300
|
+
for (const [col, baselineVal] of Object.entries(baselineEntry)) {
|
|
301
|
+
if (shouldSkipColumn(col) || col === 'children') continue;
|
|
302
|
+
if (col in entity) continue;
|
|
303
|
+
if (baselineVal !== null && baselineVal !== undefined && normalizeValue(baselineVal) !== '') {
|
|
304
|
+
changed.push(col);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
288
307
|
return changed;
|
|
289
308
|
}
|
|
290
309
|
|
|
310
|
+
// ─── Bin Delta Detection ─────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
/** Columns tracked for bin change detection */
|
|
313
|
+
const BIN_TRACKED_COLUMNS = ['Name', 'Path', 'ParentBinID', 'Active', 'Public'];
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Detect changes between a current bin entry (from structure.json) and the
|
|
317
|
+
* baseline (from app.json children.bin array).
|
|
318
|
+
*
|
|
319
|
+
* @param {Object} binEntry - Current bin entry from structure.json (with binId, name, path, etc.)
|
|
320
|
+
* @param {Object} baseline - The baseline JSON (app.json)
|
|
321
|
+
* @returns {string[]} - Array of changed column names
|
|
322
|
+
*/
|
|
323
|
+
export function detectBinChanges(binEntry, baseline) {
|
|
324
|
+
if (!baseline || !baseline.children || !Array.isArray(baseline.children.bin)) {
|
|
325
|
+
return BIN_TRACKED_COLUMNS; // no baseline → treat all as changed
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const baselineBin = baseline.children.bin.find(
|
|
329
|
+
b => b.UID === binEntry.uid || b.BinID === binEntry.binId
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
if (!baselineBin) {
|
|
333
|
+
return BIN_TRACKED_COLUMNS; // new bin, not in baseline
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const changed = [];
|
|
337
|
+
|
|
338
|
+
// Map structure.json field names → server column names for comparison
|
|
339
|
+
const fieldMap = {
|
|
340
|
+
Name: { current: binEntry.name, baseline: baselineBin.Name },
|
|
341
|
+
Path: { current: binEntry.path, baseline: baselineBin.Path },
|
|
342
|
+
ParentBinID: { current: binEntry.parentBinID, baseline: baselineBin.ParentBinID },
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
for (const [col, { current, baseline: base }] of Object.entries(fieldMap)) {
|
|
346
|
+
const curStr = current !== null && current !== undefined ? String(current) : '';
|
|
347
|
+
const baseStr = base !== null && base !== undefined ? String(base) : '';
|
|
348
|
+
if (curStr !== baseStr) changed.push(col);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Direct column comparisons for Active/Public (may exist on baseline)
|
|
352
|
+
for (const col of ['Active', 'Public']) {
|
|
353
|
+
if (baselineBin[col] !== undefined && binEntry[col] !== undefined) {
|
|
354
|
+
if (String(binEntry[col]) !== String(baselineBin[col])) {
|
|
355
|
+
changed.push(col);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return changed;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Synthesize a virtual metadata object for a bin entry (for use with pushFromMetadata).
|
|
365
|
+
*
|
|
366
|
+
* @param {Object} binEntry - Bin entry from structure.json
|
|
367
|
+
* @param {number} [appId] - AppID from app config
|
|
368
|
+
* @returns {Object} - Virtual metadata object
|
|
369
|
+
*/
|
|
370
|
+
export function synthesizeBinMetadata(binEntry, appId) {
|
|
371
|
+
const meta = {
|
|
372
|
+
_entity: 'bin',
|
|
373
|
+
_id: binEntry.binId,
|
|
374
|
+
UID: binEntry.uid,
|
|
375
|
+
Name: binEntry.name,
|
|
376
|
+
Path: binEntry.path,
|
|
377
|
+
ParentBinID: binEntry.parentBinID,
|
|
378
|
+
};
|
|
379
|
+
if (appId) meta.AppID = appId;
|
|
380
|
+
return meta;
|
|
381
|
+
}
|
|
382
|
+
|
|
291
383
|
async function _walkChildrenForChanges(childrenObj, baseline, metaDir, result) {
|
|
292
384
|
for (const docKey of _OUTPUT_DOC_KEYS) {
|
|
293
385
|
const entityArray = childrenObj[docKey];
|
package/src/lib/diff.js
CHANGED
|
@@ -3,9 +3,120 @@ import { readFile, writeFile, readdir, access, stat } from 'fs/promises';
|
|
|
3
3
|
import { join, dirname, basename, extname, relative } from 'path';
|
|
4
4
|
import { loadIgnore } from './ignore.js';
|
|
5
5
|
import { parseServerDate, setFileTimestamps } from './timestamps.js';
|
|
6
|
-
import { loadConfig, loadUserInfo } from './config.js';
|
|
6
|
+
import { loadConfig, loadUserInfo, loadAppJsonBaseline } from './config.js';
|
|
7
|
+
import { findBaselineEntry } from './delta.js';
|
|
7
8
|
import { log } from './logger.js';
|
|
8
9
|
|
|
10
|
+
// ─── Baseline Cache ─────────────────────────────────────────────────────────
|
|
11
|
+
// Cached baseline used by isServerNewer to compare raw _LastUpdated strings
|
|
12
|
+
// and avoid false-positive conflicts from timezone conversion drift.
|
|
13
|
+
let _cachedBaseline;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pre-load the previous baseline into memory so that isServerNewer can
|
|
17
|
+
* do a fast string comparison. Call once at the start of clone/pull.
|
|
18
|
+
*/
|
|
19
|
+
export async function loadBaselineForComparison() {
|
|
20
|
+
_cachedBaseline = await loadAppJsonBaseline();
|
|
21
|
+
return _cachedBaseline;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Clear the cached baseline.
|
|
26
|
+
* Call after saving a new baseline (e.g. after clone completes).
|
|
27
|
+
*/
|
|
28
|
+
export function resetBaselineCache() {
|
|
29
|
+
_cachedBaseline = undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Look up the baseline _LastUpdated for a given entity/uid pair.
|
|
34
|
+
* Requires loadBaselineForComparison() to have been called first.
|
|
35
|
+
*/
|
|
36
|
+
function getBaselineLastUpdated(entity, uid) {
|
|
37
|
+
if (!_cachedBaseline) return undefined;
|
|
38
|
+
const entry = findBaselineEntry(_cachedBaseline, entity, uid);
|
|
39
|
+
return entry?._LastUpdated;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── File Finding ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recursively search the project directory for a file matching a bare filename.
|
|
46
|
+
* Respects .dboignore and skips known non-project dirs.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} filename - Bare filename (e.g., "myfile.md", "myfile.metadata.json")
|
|
49
|
+
* @param {string} [dir=process.cwd()] - Starting directory
|
|
50
|
+
* @param {import('ignore').Ignore} [ig] - Ignore instance
|
|
51
|
+
* @returns {Promise<string[]>} - Array of matching absolute paths
|
|
52
|
+
*/
|
|
53
|
+
export async function findFileInProject(filename, dir, ig) {
|
|
54
|
+
if (!dir) dir = process.cwd();
|
|
55
|
+
if (!ig) ig = await loadIgnore();
|
|
56
|
+
|
|
57
|
+
const results = [];
|
|
58
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
59
|
+
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const fullPath = join(dir, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
64
|
+
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
65
|
+
results.push(...await findFileInProject(filename, fullPath, ig));
|
|
66
|
+
} else if (entry.name === filename) {
|
|
67
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
68
|
+
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── UID Lookup ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Find records by UID across all metadata files and structure.json bins.
|
|
79
|
+
*
|
|
80
|
+
* @param {string[]} uids - Array of UIDs to search for
|
|
81
|
+
* @returns {Promise<Array<{ uid: string, metaPath?: string, meta?: Object, binEntry?: Object }>>}
|
|
82
|
+
*/
|
|
83
|
+
export async function findByUID(uids) {
|
|
84
|
+
const uidSet = new Set(uids);
|
|
85
|
+
const results = [];
|
|
86
|
+
const found = new Set();
|
|
87
|
+
|
|
88
|
+
// Search metadata files
|
|
89
|
+
const ig = await loadIgnore();
|
|
90
|
+
const metaFiles = await findMetadataFiles(process.cwd(), ig);
|
|
91
|
+
|
|
92
|
+
for (const metaPath of metaFiles) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = await readFile(metaPath, 'utf8');
|
|
95
|
+
const meta = JSON.parse(raw);
|
|
96
|
+
if (meta.UID && uidSet.has(meta.UID)) {
|
|
97
|
+
results.push({ uid: meta.UID, metaPath, meta });
|
|
98
|
+
found.add(meta.UID);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* skip unreadable files */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Search structure.json bin entries
|
|
104
|
+
if (found.size < uidSet.size) {
|
|
105
|
+
try {
|
|
106
|
+
const { loadStructureFile } = await import('./structure.js');
|
|
107
|
+
const structure = await loadStructureFile();
|
|
108
|
+
for (const [binId, entry] of Object.entries(structure)) {
|
|
109
|
+
if (entry.uid && uidSet.has(entry.uid) && !found.has(entry.uid)) {
|
|
110
|
+
results.push({ uid: entry.uid, binEntry: { binId: Number(binId), ...entry } });
|
|
111
|
+
found.add(entry.uid);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch { /* no structure file */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
119
|
+
|
|
9
120
|
// ─── Content Value Resolution ───────────────────────────────────────────────
|
|
10
121
|
|
|
11
122
|
/**
|
|
@@ -30,7 +141,7 @@ async function fileExists(path) {
|
|
|
30
141
|
|
|
31
142
|
/**
|
|
32
143
|
* Recursively find all metadata files in a directory.
|
|
33
|
-
* Includes .metadata.json files and output hierarchy files (
|
|
144
|
+
* Includes .metadata.json files and output hierarchy files (Name~UID.json).
|
|
34
145
|
*/
|
|
35
146
|
export async function findMetadataFiles(dir, ig) {
|
|
36
147
|
if (!ig) ig = await loadIgnore();
|
|
@@ -47,11 +158,10 @@ export async function findMetadataFiles(dir, ig) {
|
|
|
47
158
|
} else if (entry.name.endsWith('.metadata.json')) {
|
|
48
159
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
49
160
|
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
50
|
-
} else if (entry.name.
|
|
51
|
-
// Output hierarchy root files
|
|
161
|
+
} else if (entry.name.endsWith('.json') && !entry.name.endsWith('.metadata.json') && !entry.name.includes('.CustomSQL.') && entry.name.includes('~')) {
|
|
162
|
+
// Output hierarchy root files: Name~UID.json (or legacy _output~Name~UID.json)
|
|
52
163
|
// Exclude old-format child output files — they contain a dot-prefixed
|
|
53
164
|
// child-type segment (.column~, .join~, .filter~) before .json.
|
|
54
|
-
// Root output files never have these segments, even when <name> contains dots.
|
|
55
165
|
const isChildFile = /\.(column|join|filter)~/.test(entry.name);
|
|
56
166
|
if (!isChildFile) {
|
|
57
167
|
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
@@ -140,11 +250,30 @@ export async function hasLocalModifications(metaPath, config = {}) {
|
|
|
140
250
|
/**
|
|
141
251
|
* Compare local sync time against server's _LastUpdated.
|
|
142
252
|
* Returns true if the server record is newer than local files.
|
|
253
|
+
*
|
|
254
|
+
* When entity + uid are provided AND a baseline has been pre-loaded via
|
|
255
|
+
* loadBaselineForComparison(), the raw _LastUpdated strings are compared
|
|
256
|
+
* first. If they match exactly, the record hasn't changed on the server
|
|
257
|
+
* and we skip the timezone conversion entirely — preventing false positives
|
|
258
|
+
* caused by DST-related rounding in the mtime ↔ parseServerDate round-trip.
|
|
259
|
+
*
|
|
260
|
+
* @param {Date} localSyncTime - File mtime (from getLocalSyncTime)
|
|
261
|
+
* @param {string} serverLastUpdated - Raw _LastUpdated from the fresh server fetch
|
|
262
|
+
* @param {Object} config - Must include ServerTimezone
|
|
263
|
+
* @param {string} [entity] - Entity name for baseline lookup
|
|
264
|
+
* @param {string} [uid] - Record UID for baseline lookup
|
|
143
265
|
*/
|
|
144
|
-
export function isServerNewer(localSyncTime, serverLastUpdated, config) {
|
|
266
|
+
export function isServerNewer(localSyncTime, serverLastUpdated, config, entity, uid) {
|
|
145
267
|
if (!serverLastUpdated) return false;
|
|
146
268
|
if (!localSyncTime) return true;
|
|
147
269
|
|
|
270
|
+
// Fast path: compare raw server string against baseline.
|
|
271
|
+
// If identical, the record hasn't changed — no timezone math needed.
|
|
272
|
+
if (entity && uid) {
|
|
273
|
+
const baselineLastUpdated = getBaselineLastUpdated(entity, uid);
|
|
274
|
+
if (baselineLastUpdated && serverLastUpdated === baselineLastUpdated) return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
148
277
|
const serverTz = config.ServerTimezone;
|
|
149
278
|
const serverDate = parseServerDate(serverLastUpdated, serverTz);
|
|
150
279
|
if (!serverDate) return false;
|
|
@@ -155,17 +284,8 @@ export function isServerNewer(localSyncTime, serverLastUpdated, config) {
|
|
|
155
284
|
|
|
156
285
|
// ─── Server Fetching ────────────────────────────────────────────────────────
|
|
157
286
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
*/
|
|
161
|
-
export async function fetchServerRecord(client, entity, uid) {
|
|
162
|
-
const result = await client.get(`/api/output/entity/${entity}/${uid}`, {
|
|
163
|
-
'_format': 'json_raw',
|
|
164
|
-
});
|
|
165
|
-
const data = result.payload || result.data;
|
|
166
|
-
const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || [data]);
|
|
167
|
-
return rows.length > 0 ? rows[0] : null;
|
|
168
|
-
}
|
|
287
|
+
// Per-record fetch removed — all server record fetching now goes through
|
|
288
|
+
// fetchServerRecordsBatch() in toe-stepping.js via /api/app/object/.
|
|
169
289
|
|
|
170
290
|
// ─── Diff Algorithm ─────────────────────────────────────────────────────────
|
|
171
291
|
|
|
@@ -327,8 +447,12 @@ function buildHunk(entries, start, end) {
|
|
|
327
447
|
/**
|
|
328
448
|
* Compare a local record (metadata.json + content files) against the server.
|
|
329
449
|
* Returns a DiffResult object.
|
|
450
|
+
*
|
|
451
|
+
* @param {string} metaPath - Absolute path to the .metadata.json file
|
|
452
|
+
* @param {Object} config
|
|
453
|
+
* @param {Map<string, Object>} serverRecordsMap - Pre-fetched server records (uid → record)
|
|
330
454
|
*/
|
|
331
|
-
export async function compareRecord(metaPath,
|
|
455
|
+
export async function compareRecord(metaPath, config, serverRecordsMap) {
|
|
332
456
|
let localMeta;
|
|
333
457
|
try {
|
|
334
458
|
localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
@@ -343,7 +467,7 @@ export async function compareRecord(metaPath, client, config) {
|
|
|
343
467
|
return { error: `Missing _entity or UID in ${metaPath}` };
|
|
344
468
|
}
|
|
345
469
|
|
|
346
|
-
const serverRecord =
|
|
470
|
+
const serverRecord = serverRecordsMap?.get(uid) || null;
|
|
347
471
|
if (!serverRecord) {
|
|
348
472
|
return { error: `Record not found on server: ${entity}/${uid}` };
|
|
349
473
|
}
|
package/src/lib/ignore.js
CHANGED
|
@@ -16,7 +16,6 @@ const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
|
|
|
16
16
|
.dboignore
|
|
17
17
|
*.dboio.json
|
|
18
18
|
app.json
|
|
19
|
-
dbo.deploy.json
|
|
20
19
|
|
|
21
20
|
# Editor / IDE / OS
|
|
22
21
|
.DS_Store
|
|
@@ -43,7 +42,7 @@ package-lock.json
|
|
|
43
42
|
|
|
44
43
|
# Local development (not pushed to DBO server)
|
|
45
44
|
src/
|
|
46
|
-
|
|
45
|
+
test/
|
|
47
46
|
|
|
48
47
|
# Documentation (repo scaffolding)
|
|
49
48
|
SETUP.md
|
|
@@ -28,20 +28,37 @@ export function resolveDirective(filePath) {
|
|
|
28
28
|
const parts = rel.split('/');
|
|
29
29
|
const topDir = parts[0];
|
|
30
30
|
|
|
31
|
-
// docs/ prefix → static mapping
|
|
31
|
+
// docs/ prefix at project root → static mapping (docs/ stays at root)
|
|
32
32
|
if (topDir === 'docs') {
|
|
33
33
|
return { ...STATIC_DIRECTIVE_MAP.docs };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// lib/<subDir>/... — all server-managed dirs are now under lib/
|
|
37
|
+
if (topDir === 'lib') {
|
|
38
|
+
const subDir = parts[1];
|
|
39
|
+
if (!subDir) return null;
|
|
40
|
+
|
|
41
|
+
// lib/extension/<descriptor>/<file> — need at least 4 parts
|
|
42
|
+
if (subDir === 'extension') {
|
|
43
|
+
if (parts.length < 4) return null;
|
|
44
|
+
const descriptorDir = parts[2];
|
|
45
|
+
if (descriptorDir.startsWith('.') || descriptorDir === '_unsupported') return null;
|
|
46
|
+
return { entity: 'extension', descriptor: descriptorDir };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// lib/<entityType>/<file> — other entity dirs
|
|
50
|
+
if (ENTITY_DIR_NAMES.has(subDir)) {
|
|
51
|
+
return { entity: subDir, descriptor: null };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Legacy fallback: bare entity-dir paths (pre-migration projects)
|
|
37
56
|
if (topDir === 'extension') {
|
|
38
57
|
if (parts.length < 3) return null;
|
|
39
58
|
const secondDir = parts[1];
|
|
40
59
|
if (secondDir.startsWith('.')) return null;
|
|
41
60
|
return { entity: 'extension', descriptor: secondDir };
|
|
42
61
|
}
|
|
43
|
-
|
|
44
|
-
// Other entity-dir types
|
|
45
62
|
if (ENTITY_DIR_NAMES.has(topDir)) {
|
|
46
63
|
return { entity: topDir, descriptor: null };
|
|
47
64
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { isInitialized, loadCompletedMigrations, saveCompletedMigration } from './config.js';
|
|
5
|
+
import { log } from './logger.js';
|
|
6
|
+
|
|
7
|
+
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../migrations');
|
|
8
|
+
const MIGRATION_FILE_RE = /^(\d{3})-[\w-]+\.js$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run all pending migrations in sequence.
|
|
12
|
+
* Called at the start of every command handler unless options.migrate === false.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} options - Command options object (checked for migrate flag)
|
|
15
|
+
*/
|
|
16
|
+
export async function runPendingMigrations(options = {}) {
|
|
17
|
+
// --no-migrate suppresses for this invocation
|
|
18
|
+
if (options.migrate === false) return;
|
|
19
|
+
|
|
20
|
+
// No .dbo/ project in this directory — nothing to migrate
|
|
21
|
+
if (!(await isInitialized())) return;
|
|
22
|
+
|
|
23
|
+
// Discover migration files
|
|
24
|
+
let files;
|
|
25
|
+
try {
|
|
26
|
+
files = await readdir(MIGRATIONS_DIR);
|
|
27
|
+
} catch {
|
|
28
|
+
return; // migrations/ directory absent — safe no-op
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const migrationFiles = files
|
|
32
|
+
.filter(f => MIGRATION_FILE_RE.test(f))
|
|
33
|
+
.sort(); // ascending by NNN prefix
|
|
34
|
+
|
|
35
|
+
if (migrationFiles.length === 0) return;
|
|
36
|
+
|
|
37
|
+
const completed = new Set(await loadCompletedMigrations());
|
|
38
|
+
const pending = migrationFiles.filter(f => {
|
|
39
|
+
const id = f.match(MIGRATION_FILE_RE)?.[1];
|
|
40
|
+
return id && !completed.has(id);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (pending.length === 0) return;
|
|
44
|
+
|
|
45
|
+
for (const file of pending) {
|
|
46
|
+
const id = file.match(MIGRATION_FILE_RE)[1];
|
|
47
|
+
try {
|
|
48
|
+
const migrationPath = join(MIGRATIONS_DIR, file);
|
|
49
|
+
const { default: run, description } = await import(migrationPath);
|
|
50
|
+
await run(options);
|
|
51
|
+
await saveCompletedMigration(id);
|
|
52
|
+
log.success(`Migration ${id}: ${description}`);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
log.warn(`Migration ${id} failed: ${err.message} — skipped`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Count pending (unrun) migrations without executing them.
|
|
61
|
+
* Used by `dbo status` to report pending count.
|
|
62
|
+
*
|
|
63
|
+
* @returns {Promise<number>}
|
|
64
|
+
*/
|
|
65
|
+
export async function countPendingMigrations() {
|
|
66
|
+
if (!(await isInitialized())) return 0;
|
|
67
|
+
let files;
|
|
68
|
+
try { files = await readdir(MIGRATIONS_DIR); } catch { return 0; }
|
|
69
|
+
const migrationFiles = files.filter(f => MIGRATION_FILE_RE.test(f));
|
|
70
|
+
const completed = new Set(await loadCompletedMigrations());
|
|
71
|
+
return migrationFiles.filter(f => {
|
|
72
|
+
const id = f.match(MIGRATION_FILE_RE)?.[1];
|
|
73
|
+
return id && !completed.has(id);
|
|
74
|
+
}).length;
|
|
75
|
+
}
|
package/src/lib/save-to-disk.js
CHANGED
|
@@ -230,7 +230,7 @@ export async function saveToDisk(rows, columns, options = {}) {
|
|
|
230
230
|
|
|
231
231
|
if (bulkAction !== 'overwrite_all') {
|
|
232
232
|
const localSyncTime = await diffModule.getLocalSyncTime(metaPath);
|
|
233
|
-
const serverIsNewer = diffModule.isServerNewer(localSyncTime, row._LastUpdated, options.config || {});
|
|
233
|
+
const serverIsNewer = diffModule.isServerNewer(localSyncTime, row._LastUpdated, options.config || {}, options.entity, row.UID);
|
|
234
234
|
|
|
235
235
|
if (serverIsNewer) {
|
|
236
236
|
const action = await diffModule.promptChangeDetection(finalName, row, options.config || {});
|
package/src/lib/scaffold.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, stat, readFile, writeFile, access } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { SCAFFOLD_DIRS } from './structure.js';
|
|
4
4
|
import { log } from './logger.js';
|
|
5
5
|
import { applyTrashIcon } from './folder-icon.js';
|
|
6
6
|
|
|
@@ -9,20 +9,15 @@ import { applyTrashIcon } from './folder-icon.js';
|
|
|
9
9
|
* Creates missing directories, skips existing ones, warns on name conflicts.
|
|
10
10
|
* Also creates app.json with {} if absent.
|
|
11
11
|
*
|
|
12
|
-
* When appShortName is provided, also creates media sub-directories:
|
|
13
|
-
* media/<appShortName>/app/
|
|
14
|
-
* media/<appShortName>/user/
|
|
15
|
-
*
|
|
16
12
|
* @param {string} [cwd=process.cwd()]
|
|
17
|
-
* @param {{ appShortName?: string }} [options]
|
|
18
13
|
* @returns {Promise<{ created: string[], skipped: string[], warned: string[] }>}
|
|
19
14
|
*/
|
|
20
|
-
export async function scaffoldProjectDirs(cwd = process.cwd()
|
|
15
|
+
export async function scaffoldProjectDirs(cwd = process.cwd()) {
|
|
21
16
|
const created = [];
|
|
22
17
|
const skipped = [];
|
|
23
18
|
const warned = [];
|
|
24
19
|
|
|
25
|
-
for (const dir of
|
|
20
|
+
for (const dir of SCAFFOLD_DIRS) {
|
|
26
21
|
const target = join(cwd, dir);
|
|
27
22
|
try {
|
|
28
23
|
const s = await stat(target);
|
|
@@ -39,26 +34,6 @@ export async function scaffoldProjectDirs(cwd = process.cwd(), options = {}) {
|
|
|
39
34
|
}
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
// Create media sub-directories when app short name is known:
|
|
43
|
-
// media/<appShortName>/app/ — app-level media assets
|
|
44
|
-
// media/<appShortName>/user/ — user-uploaded media
|
|
45
|
-
if (options.appShortName) {
|
|
46
|
-
const mediaSubs = [
|
|
47
|
-
`media/${options.appShortName}/app`,
|
|
48
|
-
`media/${options.appShortName}/user`,
|
|
49
|
-
];
|
|
50
|
-
for (const sub of mediaSubs) {
|
|
51
|
-
const target = join(cwd, sub);
|
|
52
|
-
try {
|
|
53
|
-
await stat(target);
|
|
54
|
-
skipped.push(sub);
|
|
55
|
-
} catch {
|
|
56
|
-
await mkdir(target, { recursive: true });
|
|
57
|
-
created.push(sub);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
37
|
// Best-effort: apply trash icon to the trash directory
|
|
63
38
|
await applyTrashIcon(join(cwd, 'trash'));
|
|
64
39
|
|