@dboio/cli 0.7.2 → 0.8.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 +153 -11
- package/package.json +3 -2
- package/src/commands/add.js +64 -11
- package/src/commands/clone.js +749 -63
- package/src/commands/init.js +28 -4
- package/src/commands/install.js +10 -1
- package/src/commands/login.js +69 -0
- package/src/commands/push.js +102 -18
- package/src/lib/config.js +101 -0
- package/src/lib/delta.js +14 -1
- package/src/lib/diff.js +71 -15
- package/src/lib/ignore.js +145 -0
- package/src/lib/structure.js +114 -0
- package/src/lib/ticketing.js +6 -3
- package/src/lib/timestamps.js +31 -9
package/src/commands/init.js
CHANGED
|
@@ -4,8 +4,10 @@ import { join } from 'path';
|
|
|
4
4
|
import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
|
|
5
5
|
import { installOrUpdateClaudeCommands } from './install.js';
|
|
6
6
|
import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
|
|
7
|
+
import { createDboignore, loadIgnore } from '../lib/ignore.js';
|
|
7
8
|
import { log } from '../lib/logger.js';
|
|
8
9
|
import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
|
|
10
|
+
import { performLogin } from './login.js';
|
|
9
11
|
|
|
10
12
|
export const initCommand = new Command('init')
|
|
11
13
|
.description('Initialize DBO CLI configuration for the current directory')
|
|
@@ -19,10 +21,22 @@ export const initCommand = new Command('init')
|
|
|
19
21
|
.option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
|
|
20
22
|
.option('--local', 'Install Claude commands to project directory (.claude/commands/)')
|
|
21
23
|
.option('--scaffold', 'Create standard project directories (App Versions, Automations, Bins, …)')
|
|
24
|
+
.option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
|
|
22
25
|
.action(async (options) => {
|
|
23
26
|
// Merge --yes into nonInteractive
|
|
24
27
|
if (options.yes) options.nonInteractive = true;
|
|
25
28
|
try {
|
|
29
|
+
// --dboignore: standalone operation, works regardless of init state
|
|
30
|
+
if (options.dboignore) {
|
|
31
|
+
const created = await createDboignore(process.cwd(), { force: options.force });
|
|
32
|
+
if (created) {
|
|
33
|
+
log.success(options.force ? 'Reset .dboignore to default patterns' : 'Created .dboignore with default patterns');
|
|
34
|
+
} else {
|
|
35
|
+
log.warn('.dboignore already exists. Use --force to overwrite with defaults.');
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
if (await isInitialized() && !options.force) {
|
|
27
41
|
if (options.scaffold) {
|
|
28
42
|
const result = await scaffoldProjectDirs();
|
|
@@ -86,6 +100,9 @@ export const initCommand = new Command('init')
|
|
|
86
100
|
// Ensure sensitive files are gitignored
|
|
87
101
|
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
|
|
88
102
|
|
|
103
|
+
const createdIgnore = await createDboignore();
|
|
104
|
+
if (createdIgnore) log.dim(' Created .dboignore');
|
|
105
|
+
|
|
89
106
|
log.success(`Initialized .dbo/ for ${domain}`);
|
|
90
107
|
log.dim(' Run "dbo login" to authenticate.');
|
|
91
108
|
|
|
@@ -130,9 +147,9 @@ export const initCommand = new Command('init')
|
|
|
130
147
|
let shouldScaffold = options.scaffold;
|
|
131
148
|
|
|
132
149
|
if (!shouldScaffold && !options.nonInteractive) {
|
|
133
|
-
const entries = await readdir(process.cwd());
|
|
134
|
-
const
|
|
135
|
-
const isEmpty = entries.every(e =>
|
|
150
|
+
const entries = await readdir(process.cwd(), { withFileTypes: true });
|
|
151
|
+
const ig = await loadIgnore();
|
|
152
|
+
const isEmpty = entries.every(e => ig.ignores(e.isDirectory() ? e.name + '/' : e.name));
|
|
136
153
|
|
|
137
154
|
const inquirer = (await import('inquirer')).default;
|
|
138
155
|
const { doScaffold } = await inquirer.prompt([{
|
|
@@ -149,7 +166,7 @@ export const initCommand = new Command('init')
|
|
|
149
166
|
logScaffoldResult(result);
|
|
150
167
|
}
|
|
151
168
|
|
|
152
|
-
// Clone if requested
|
|
169
|
+
// Clone if requested — requires authentication first
|
|
153
170
|
if (options.clone || options.app) {
|
|
154
171
|
let appShortName = options.app;
|
|
155
172
|
if (!appShortName) {
|
|
@@ -161,6 +178,13 @@ export const initCommand = new Command('init')
|
|
|
161
178
|
}]);
|
|
162
179
|
appShortName = appName;
|
|
163
180
|
}
|
|
181
|
+
|
|
182
|
+
// Authenticate before fetching app data from the server
|
|
183
|
+
if (!options.nonInteractive) {
|
|
184
|
+
log.info('Login required to fetch app data from the server.');
|
|
185
|
+
await performLogin(domain, username);
|
|
186
|
+
}
|
|
187
|
+
|
|
164
188
|
const { performClone } = await import('./clone.js');
|
|
165
189
|
await performClone(null, { app: appShortName, domain });
|
|
166
190
|
}
|
package/src/commands/install.js
CHANGED
|
@@ -235,7 +235,7 @@ async function promptForScope(pluginName) {
|
|
|
235
235
|
|
|
236
236
|
/**
|
|
237
237
|
* Resolve the scope for a plugin based on flags and stored preferences.
|
|
238
|
-
* Priority: explicit flag > stored preference > prompt.
|
|
238
|
+
* Priority: explicit flag > stored preference > existing installation > prompt.
|
|
239
239
|
* @param {string} pluginName - Plugin name without .md
|
|
240
240
|
* @param {object} options - Command options with global/local flags
|
|
241
241
|
* @returns {Promise<'project' | 'global'>}
|
|
@@ -247,6 +247,15 @@ async function resolvePluginScope(pluginName, options) {
|
|
|
247
247
|
const storedScope = await getPluginScope(pluginName);
|
|
248
248
|
if (storedScope) return storedScope;
|
|
249
249
|
|
|
250
|
+
// Infer from existing installation — avoids re-prompting on re-installs
|
|
251
|
+
// (e.g. postinstall after npm install when .dbo/ isn't in cwd)
|
|
252
|
+
const registry = await readPluginRegistry();
|
|
253
|
+
const key = `${pluginName}@${PLUGIN_MARKETPLACE}`;
|
|
254
|
+
if (registry.plugins[key]) return 'global';
|
|
255
|
+
|
|
256
|
+
const projectPluginDir = join(process.cwd(), '.claude', 'plugins', pluginName);
|
|
257
|
+
if (existsSync(projectPluginDir)) return 'project';
|
|
258
|
+
|
|
250
259
|
return await promptForScope(pluginName);
|
|
251
260
|
}
|
|
252
261
|
|
package/src/commands/login.js
CHANGED
|
@@ -3,6 +3,75 @@ import { loadConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../l
|
|
|
3
3
|
import { DboClient } from '../lib/client.js';
|
|
4
4
|
import { log } from '../lib/logger.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Perform authentication against a DBO instance.
|
|
8
|
+
* Prompts for missing credentials interactively.
|
|
9
|
+
* Returns true on success, throws on failure.
|
|
10
|
+
*
|
|
11
|
+
* @param {string|null} domain - Override domain (null = use config)
|
|
12
|
+
* @param {string|null} knownUsername - Pre-filled username (will prompt if null)
|
|
13
|
+
*/
|
|
14
|
+
export async function performLogin(domain, knownUsername) {
|
|
15
|
+
const config = await loadConfig();
|
|
16
|
+
const client = new DboClient({ domain });
|
|
17
|
+
|
|
18
|
+
let username = knownUsername || config.username;
|
|
19
|
+
let password;
|
|
20
|
+
|
|
21
|
+
// Interactive prompt for missing credentials
|
|
22
|
+
const inquirer = (await import('inquirer')).default;
|
|
23
|
+
const answers = await inquirer.prompt([
|
|
24
|
+
{ type: 'input', name: 'username', message: 'Username (email):', default: username || undefined, when: !username },
|
|
25
|
+
{ type: 'password', name: 'password', message: 'Password:', mask: '*' },
|
|
26
|
+
]);
|
|
27
|
+
username = username || answers.username;
|
|
28
|
+
password = answers.password;
|
|
29
|
+
|
|
30
|
+
const params = new URLSearchParams();
|
|
31
|
+
params.append('_username', username);
|
|
32
|
+
params.append('_password', password);
|
|
33
|
+
|
|
34
|
+
const result = await client.postUrlEncoded('/api/authenticate', params.toString());
|
|
35
|
+
|
|
36
|
+
if (!result.successful) {
|
|
37
|
+
throw new Error('Authentication failed');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
|
|
41
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
|
|
42
|
+
|
|
43
|
+
// Fetch and store user info (non-critical)
|
|
44
|
+
try {
|
|
45
|
+
const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_format': 'json_raw' });
|
|
46
|
+
const userData = userResult.payload || userResult.data;
|
|
47
|
+
const rows = Array.isArray(userData) ? userData : (userData?.Rows || userData?.rows || []);
|
|
48
|
+
if (rows.length > 0) {
|
|
49
|
+
const row = rows[0];
|
|
50
|
+
const userId = row.ID || row.id || row.UserID || row.userId;
|
|
51
|
+
if (userId) {
|
|
52
|
+
await saveUserInfo({ userId: String(userId) });
|
|
53
|
+
log.dim(` User ID: ${userId}`);
|
|
54
|
+
}
|
|
55
|
+
const firstName = row.FirstName || row.firstname || row.first_name;
|
|
56
|
+
const lastName = row.LastName || row.lastname || row.last_name;
|
|
57
|
+
const email = row.Email || row.email;
|
|
58
|
+
const profile = {};
|
|
59
|
+
if (firstName) profile.FirstName = firstName;
|
|
60
|
+
if (lastName) profile.LastName = lastName;
|
|
61
|
+
if (email) profile.Email = email;
|
|
62
|
+
if (Object.keys(profile).length > 0) {
|
|
63
|
+
await saveUserProfile(profile);
|
|
64
|
+
if (firstName || lastName) log.dim(` Name: ${[firstName, lastName].filter(Boolean).join(' ')}`);
|
|
65
|
+
if (email) log.dim(` Email: ${email}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
log.dim(' Could not retrieve user info (non-critical)');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
6
75
|
export const loginCommand = new Command('login')
|
|
7
76
|
.description('Authenticate with a DBO.io instance')
|
|
8
77
|
.option('-u, --username <value>', 'Username')
|
package/src/commands/push.js
CHANGED
|
@@ -12,7 +12,20 @@ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/m
|
|
|
12
12
|
import { resolveTransactionKey } from '../lib/transaction-key.js';
|
|
13
13
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
14
14
|
import { findMetadataFiles } from '../lib/diff.js';
|
|
15
|
+
import { loadIgnore } from '../lib/ignore.js';
|
|
15
16
|
import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve an @reference file path to an absolute filesystem path.
|
|
20
|
+
* "@filename.ext" → relative to the metadata file's directory (existing behaviour)
|
|
21
|
+
* "@/Documentation/..." → relative to project root (process.cwd())
|
|
22
|
+
*/
|
|
23
|
+
function resolveAtReference(refFile, metaDir) {
|
|
24
|
+
if (refFile.startsWith('/')) {
|
|
25
|
+
return join(process.cwd(), refFile);
|
|
26
|
+
}
|
|
27
|
+
return join(metaDir, refFile);
|
|
28
|
+
}
|
|
16
29
|
import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
|
|
17
30
|
|
|
18
31
|
export const pushCommand = new Command('push')
|
|
@@ -166,7 +179,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
166
179
|
* Push all records found in a directory (recursive)
|
|
167
180
|
*/
|
|
168
181
|
async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
|
|
169
|
-
const
|
|
182
|
+
const ig = await loadIgnore();
|
|
183
|
+
const metaFiles = await findMetadataFiles(dirPath, ig);
|
|
170
184
|
|
|
171
185
|
if (metaFiles.length === 0) {
|
|
172
186
|
log.warn(`No .metadata.json files found in "${dirPath}".`);
|
|
@@ -214,7 +228,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
214
228
|
for (const col of contentCols) {
|
|
215
229
|
const ref = meta[col];
|
|
216
230
|
if (ref && ref.startsWith('@')) {
|
|
217
|
-
const refPath =
|
|
231
|
+
const refPath = resolveAtReference(ref.substring(1), dirname(metaPath));
|
|
218
232
|
try {
|
|
219
233
|
await stat(refPath);
|
|
220
234
|
} catch {
|
|
@@ -226,6 +240,26 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
226
240
|
}
|
|
227
241
|
if (missingFiles) { skipped++; continue; }
|
|
228
242
|
|
|
243
|
+
// Check if any companion content file is ignored by .dboignore
|
|
244
|
+
{
|
|
245
|
+
const metaDir = dirname(metaPath);
|
|
246
|
+
let contentIgnored = false;
|
|
247
|
+
for (const col of contentCols) {
|
|
248
|
+
const ref = meta[col];
|
|
249
|
+
if (ref && String(ref).startsWith('@')) {
|
|
250
|
+
const refFile = String(ref).substring(1);
|
|
251
|
+
const contentPath = resolveAtReference(refFile, metaDir);
|
|
252
|
+
const relContent = relative(process.cwd(), contentPath).replace(/\\/g, '/');
|
|
253
|
+
if (ig.ignores(relContent)) {
|
|
254
|
+
log.dim(` Skipped (dboignored): ${basename(metaPath)}`);
|
|
255
|
+
contentIgnored = true;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (contentIgnored) { skipped++; continue; }
|
|
261
|
+
}
|
|
262
|
+
|
|
229
263
|
// Detect changed columns (delta detection)
|
|
230
264
|
let changedColumns = null;
|
|
231
265
|
if (baseline) {
|
|
@@ -251,7 +285,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
251
285
|
|
|
252
286
|
// Pre-flight ticket validation (only if no --ticket flag)
|
|
253
287
|
if (!options.ticket && toPush.length > 0) {
|
|
254
|
-
const
|
|
288
|
+
const recordSummary = toPush.map(r => basename(r.metaPath, '.metadata.json')).join(', ');
|
|
289
|
+
const ticketCheck = await checkStoredTicket(options, `${toPush.length} record(s): ${recordSummary}`);
|
|
255
290
|
if (ticketCheck.cancel) {
|
|
256
291
|
log.info('Submission cancelled');
|
|
257
292
|
return;
|
|
@@ -378,38 +413,68 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
378
413
|
if (strValue.startsWith('@')) {
|
|
379
414
|
// @filename reference — resolve to actual file path
|
|
380
415
|
const refFile = strValue.substring(1);
|
|
381
|
-
const refPath =
|
|
416
|
+
const refPath = resolveAtReference(refFile, metaDir);
|
|
382
417
|
dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}@${refPath}`);
|
|
383
418
|
} else {
|
|
384
419
|
dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}=${strValue}`);
|
|
385
420
|
}
|
|
386
421
|
}
|
|
387
422
|
|
|
388
|
-
|
|
423
|
+
// Detect media file upload (binary file changed for media entity)
|
|
424
|
+
const isMediaUpload = entity === 'media' && meta._mediaFile
|
|
425
|
+
&& String(meta._mediaFile).startsWith('@')
|
|
426
|
+
&& changedColumns?.includes('_mediaFile');
|
|
427
|
+
|
|
428
|
+
if (dataExprs.length === 0 && !isMediaUpload) {
|
|
389
429
|
log.warn(`Nothing to push for ${basename(metaPath)}`);
|
|
390
430
|
return false;
|
|
391
431
|
}
|
|
392
432
|
|
|
393
|
-
const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
|
|
433
|
+
const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)${isMediaUpload ? ' + media file' : ''}` : `${dataExprs.length} field(s)`;
|
|
394
434
|
log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
|
|
395
435
|
|
|
396
436
|
// Apply stored ticket if no --ticket flag
|
|
397
|
-
await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
|
|
437
|
+
const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
|
|
398
438
|
|
|
399
439
|
const extraParams = { '_confirm': options.confirm };
|
|
400
440
|
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
441
|
+
else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
|
|
401
442
|
if (modifyKey) extraParams['_modify_key'] = modifyKey;
|
|
402
443
|
|
|
403
|
-
let
|
|
404
|
-
|
|
444
|
+
let result;
|
|
445
|
+
|
|
446
|
+
if (isMediaUpload) {
|
|
447
|
+
// Media file upload: use multipart/form-data
|
|
448
|
+
const mediaFileName = String(meta._mediaFile).substring(1);
|
|
449
|
+
const mediaFilePath = resolveAtReference(mediaFileName, metaDir);
|
|
450
|
+
|
|
451
|
+
// Ensure at least one data expression to identify the row for the server
|
|
452
|
+
if (dataExprs.length === 0 && meta.Filename) {
|
|
453
|
+
dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.Filename=${meta.Filename}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const fields = { ...extraParams };
|
|
457
|
+
for (const expr of dataExprs) {
|
|
458
|
+
const eqIdx = expr.indexOf('=');
|
|
459
|
+
if (eqIdx !== -1) {
|
|
460
|
+
fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
405
463
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
extraParams['_modify_key'] = retryMK.modifyKey;
|
|
411
|
-
body = await buildInputBody(dataExprs, extraParams);
|
|
464
|
+
const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaFileName }];
|
|
465
|
+
result = await client.postMultipart('/api/input/submit', fields, files);
|
|
466
|
+
} else {
|
|
467
|
+
let body = await buildInputBody(dataExprs, extraParams);
|
|
412
468
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
469
|
+
|
|
470
|
+
// Reactive ModifyKey retry — server rejected because key wasn't set locally
|
|
471
|
+
if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
|
|
472
|
+
const retryMK = await handleModifyKeyError();
|
|
473
|
+
if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
|
|
474
|
+
extraParams['_modify_key'] = retryMK.modifyKey;
|
|
475
|
+
body = await buildInputBody(dataExprs, extraParams);
|
|
476
|
+
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
477
|
+
}
|
|
413
478
|
}
|
|
414
479
|
|
|
415
480
|
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
@@ -433,8 +498,22 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
433
498
|
const params = retryResult.retryParams || retryResult;
|
|
434
499
|
Object.assign(extraParams, params);
|
|
435
500
|
|
|
436
|
-
|
|
437
|
-
|
|
501
|
+
if (isMediaUpload) {
|
|
502
|
+
const mediaFileName = String(meta._mediaFile).substring(1);
|
|
503
|
+
const mediaFilePath = resolveAtReference(mediaFileName, metaDir);
|
|
504
|
+
const fields = { ...extraParams };
|
|
505
|
+
for (const expr of dataExprs) {
|
|
506
|
+
const eqIdx = expr.indexOf('=');
|
|
507
|
+
if (eqIdx !== -1) {
|
|
508
|
+
fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaFileName }];
|
|
512
|
+
result = await client.postMultipart('/api/input/submit', fields, files);
|
|
513
|
+
} else {
|
|
514
|
+
const body = await buildInputBody(dataExprs, extraParams);
|
|
515
|
+
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
516
|
+
}
|
|
438
517
|
}
|
|
439
518
|
|
|
440
519
|
formatResponse(result, { json: options.json, jq: options.jq });
|
|
@@ -474,6 +553,11 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
474
553
|
await setFileTimestamps(contentPath, meta._CreatedOn, updated, serverTz);
|
|
475
554
|
}
|
|
476
555
|
}
|
|
556
|
+
// Update media file mtime too
|
|
557
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
558
|
+
const mediaPath = join(dirname(metaPath), String(meta._mediaFile).substring(1));
|
|
559
|
+
await setFileTimestamps(mediaPath, meta._CreatedOn, updated, serverTz);
|
|
560
|
+
}
|
|
477
561
|
}
|
|
478
562
|
}
|
|
479
563
|
}
|
|
@@ -647,7 +731,7 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
|
|
|
647
731
|
if (strValue.startsWith('@')) {
|
|
648
732
|
try {
|
|
649
733
|
const refFile = strValue.substring(1);
|
|
650
|
-
const refPath =
|
|
734
|
+
const refPath = resolveAtReference(refFile, dirname(metaPath));
|
|
651
735
|
const fileContent = await readFile(refPath, 'utf8');
|
|
652
736
|
baselineEntry[col] = fileContent;
|
|
653
737
|
modified = true;
|
package/src/lib/config.js
CHANGED
|
@@ -724,3 +724,104 @@ export async function loadAppJsonBaseline() {
|
|
|
724
724
|
export async function saveAppJsonBaseline(data) {
|
|
725
725
|
await writeFile(baselinePath(), JSON.stringify(data, null, 2) + '\n');
|
|
726
726
|
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Save the clone source to .dbo/config.json.
|
|
730
|
+
* "default" = fetched from server via AppShortName.
|
|
731
|
+
* Any other value = explicit local file path or URL provided by the user.
|
|
732
|
+
*/
|
|
733
|
+
export async function saveCloneSource(source) {
|
|
734
|
+
await mkdir(dboDir(), { recursive: true });
|
|
735
|
+
let existing = {};
|
|
736
|
+
try {
|
|
737
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
738
|
+
} catch { /* no existing config */ }
|
|
739
|
+
existing.cloneSource = source;
|
|
740
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Load the stored clone source from .dbo/config.json.
|
|
745
|
+
* Returns null if not set.
|
|
746
|
+
*/
|
|
747
|
+
export async function loadCloneSource() {
|
|
748
|
+
try {
|
|
749
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
750
|
+
const config = JSON.parse(raw);
|
|
751
|
+
return config.cloneSource || null;
|
|
752
|
+
} catch {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ─── Descriptor-level Extension Preferences ───────────────────────────────
|
|
758
|
+
|
|
759
|
+
/** Save filename column preference for a specific Descriptor value.
|
|
760
|
+
* Config key: "Extension_<descriptor>_FilenameCol"
|
|
761
|
+
*/
|
|
762
|
+
export async function saveDescriptorFilenamePreference(descriptor, columnName) {
|
|
763
|
+
await mkdir(dboDir(), { recursive: true });
|
|
764
|
+
let cfg = {};
|
|
765
|
+
try { cfg = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
|
|
766
|
+
if (columnName === null) {
|
|
767
|
+
delete cfg[`Extension_${descriptor}_FilenameCol`];
|
|
768
|
+
} else {
|
|
769
|
+
cfg[`Extension_${descriptor}_FilenameCol`] = columnName;
|
|
770
|
+
}
|
|
771
|
+
await writeFile(configPath(), JSON.stringify(cfg, null, 2) + '\n');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Load filename column preference for a specific Descriptor value. Returns null if not set. */
|
|
775
|
+
export async function loadDescriptorFilenamePreference(descriptor) {
|
|
776
|
+
try {
|
|
777
|
+
const cfg = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
778
|
+
return cfg[`Extension_${descriptor}_FilenameCol`] || null;
|
|
779
|
+
} catch { return null; }
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Save content extraction preferences for a specific Descriptor value.
|
|
783
|
+
* Config key: "Extension_<descriptor>_ContentExtractions"
|
|
784
|
+
* Value: { "ColName": "css", "Other": false, ... }
|
|
785
|
+
*/
|
|
786
|
+
export async function saveDescriptorContentExtractions(descriptor, extractions) {
|
|
787
|
+
await mkdir(dboDir(), { recursive: true });
|
|
788
|
+
let cfg = {};
|
|
789
|
+
try { cfg = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
|
|
790
|
+
if (extractions === null) {
|
|
791
|
+
delete cfg[`Extension_${descriptor}_ContentExtractions`];
|
|
792
|
+
} else {
|
|
793
|
+
cfg[`Extension_${descriptor}_ContentExtractions`] = extractions;
|
|
794
|
+
}
|
|
795
|
+
await writeFile(configPath(), JSON.stringify(cfg, null, 2) + '\n');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Load content extraction preferences for a specific Descriptor value. Returns null if not saved. */
|
|
799
|
+
export async function loadDescriptorContentExtractions(descriptor) {
|
|
800
|
+
try {
|
|
801
|
+
const cfg = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
802
|
+
return cfg[`Extension_${descriptor}_ContentExtractions`] || null;
|
|
803
|
+
} catch { return null; }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Save ExtensionDocumentationMDPlacement preference.
|
|
807
|
+
* @param {'inline'|'root'|null} placement — null clears the key
|
|
808
|
+
*/
|
|
809
|
+
export async function saveExtensionDocumentationMDPlacement(placement) {
|
|
810
|
+
await mkdir(dboDir(), { recursive: true });
|
|
811
|
+
let cfg = {};
|
|
812
|
+
try { cfg = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
|
|
813
|
+
if (placement === null) {
|
|
814
|
+
delete cfg.ExtensionDocumentationMDPlacement;
|
|
815
|
+
} else {
|
|
816
|
+
cfg.ExtensionDocumentationMDPlacement = placement;
|
|
817
|
+
}
|
|
818
|
+
await writeFile(configPath(), JSON.stringify(cfg, null, 2) + '\n');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Load ExtensionDocumentationMDPlacement preference. Returns 'inline', 'root', or null. */
|
|
822
|
+
export async function loadExtensionDocumentationMDPlacement() {
|
|
823
|
+
try {
|
|
824
|
+
const cfg = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
825
|
+
return cfg.ExtensionDocumentationMDPlacement || null;
|
|
826
|
+
} catch { return null; }
|
|
827
|
+
}
|
package/src/lib/delta.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'fs/promises';
|
|
1
|
+
import { readFile, stat } from 'fs/promises';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { log } from './logger.js';
|
|
4
4
|
|
|
@@ -132,6 +132,19 @@ export async function detectChangedColumns(metaPath, baseline) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Check _mediaFile for binary file changes (media entities)
|
|
136
|
+
if (metadata._mediaFile && isReference(metadata._mediaFile)) {
|
|
137
|
+
const mediaPath = resolveReferencePath(metadata._mediaFile, metaDir);
|
|
138
|
+
try {
|
|
139
|
+
const mediaStat = await stat(mediaPath);
|
|
140
|
+
const metaStat = await stat(metaPath);
|
|
141
|
+
// Media file modified more recently than metadata = local change
|
|
142
|
+
if (mediaStat.mtimeMs > metaStat.mtimeMs + 2000) {
|
|
143
|
+
changedColumns.push('_mediaFile');
|
|
144
|
+
}
|
|
145
|
+
} catch { /* missing file, skip */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
return changedColumns;
|
|
136
149
|
}
|
|
137
150
|
|
package/src/lib/diff.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { readFile, writeFile, readdir, access, stat } from 'fs/promises';
|
|
3
|
-
import { join, dirname, basename, extname } from 'path';
|
|
3
|
+
import { join, dirname, basename, extname, relative } from 'path';
|
|
4
|
+
import { loadIgnore } from './ignore.js';
|
|
4
5
|
import { parseServerDate, setFileTimestamps } from './timestamps.js';
|
|
5
6
|
import { loadConfig, loadUserInfo } from './config.js';
|
|
6
7
|
import { log } from './logger.js';
|
|
@@ -31,21 +32,25 @@ async function fileExists(path) {
|
|
|
31
32
|
* Recursively find all metadata files in a directory.
|
|
32
33
|
* Includes .metadata.json files and output hierarchy files (_output~*.json).
|
|
33
34
|
*/
|
|
34
|
-
export async function findMetadataFiles(dir) {
|
|
35
|
+
export async function findMetadataFiles(dir, ig) {
|
|
36
|
+
if (!ig) ig = await loadIgnore();
|
|
37
|
+
|
|
35
38
|
const results = [];
|
|
36
39
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
37
40
|
|
|
38
41
|
for (const entry of entries) {
|
|
39
42
|
const fullPath = join(dir, entry.name);
|
|
40
43
|
if (entry.isDirectory()) {
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
results.push(...await findMetadataFiles(fullPath));
|
|
44
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
45
|
+
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
46
|
+
results.push(...await findMetadataFiles(fullPath, ig));
|
|
44
47
|
} else if (entry.name.endsWith('.metadata.json')) {
|
|
45
|
-
|
|
48
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
49
|
+
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
46
50
|
} else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
|
|
47
51
|
// Output hierarchy files: _output~<name>~<uid>.json and nested entity files
|
|
48
|
-
|
|
52
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
53
|
+
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -498,21 +503,67 @@ export async function applyServerChanges(diffResult, acceptedFields, config) {
|
|
|
498
503
|
}
|
|
499
504
|
}
|
|
500
505
|
|
|
506
|
+
// ─── Diffability ─────────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
// Extensions that can be meaningfully text-diffed
|
|
509
|
+
const DIFFABLE_EXTENSIONS = new Set([
|
|
510
|
+
'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
|
|
511
|
+
'css', 'scss', 'less', 'sass',
|
|
512
|
+
'html', 'htm', 'xhtml',
|
|
513
|
+
'sql', 'xml', 'json', 'yaml', 'yml', 'toml', 'ini', 'env',
|
|
514
|
+
'md', 'txt', 'csv', 'tsv',
|
|
515
|
+
'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
|
|
516
|
+
'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go', 'rs', 'swift', 'kt',
|
|
517
|
+
'vue', 'svelte', 'astro',
|
|
518
|
+
'graphql', 'gql', 'proto',
|
|
519
|
+
'htaccess', 'gitignore', 'dockerignore', 'editorconfig',
|
|
520
|
+
]);
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Returns true if the file extension suggests it can be meaningfully text-diffed.
|
|
524
|
+
* Images, videos, audio, fonts, archives, and other binary formats return false.
|
|
525
|
+
*/
|
|
526
|
+
export function isDiffable(ext) {
|
|
527
|
+
if (!ext) return false;
|
|
528
|
+
return DIFFABLE_EXTENSIONS.has(String(ext).toLowerCase().replace(/^\./, ''));
|
|
529
|
+
}
|
|
530
|
+
|
|
501
531
|
// ─── Change Detection Prompt ────────────────────────────────────────────────
|
|
502
532
|
|
|
503
533
|
/**
|
|
504
534
|
* Build the change detection message describing who changed the file.
|
|
505
535
|
*/
|
|
506
|
-
function buildChangeMessage(recordName, serverRecord, config) {
|
|
536
|
+
function buildChangeMessage(recordName, serverRecord, config, options = {}) {
|
|
507
537
|
const userInfo = loadUserInfoSync();
|
|
508
538
|
const updatedBy = serverRecord._LastUpdatedUserID;
|
|
509
539
|
|
|
540
|
+
let who;
|
|
510
541
|
if (updatedBy && userInfo && String(updatedBy) === String(userInfo.userId)) {
|
|
511
|
-
|
|
542
|
+
who = 'you (from another session)';
|
|
512
543
|
} else if (updatedBy) {
|
|
513
|
-
|
|
544
|
+
who = `user ${updatedBy}`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const datePart = formatDateHint(options.serverDate, options.localDate);
|
|
548
|
+
|
|
549
|
+
if (who) {
|
|
550
|
+
return `"${recordName}" was updated on server by ${who}${datePart}`;
|
|
514
551
|
}
|
|
515
|
-
return `"${recordName}" has updates newer than your local version`;
|
|
552
|
+
return `"${recordName}" has updates newer than your local version${datePart}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Format a "(server: X, local: Y)" hint when date info is available.
|
|
557
|
+
*/
|
|
558
|
+
function formatDateHint(serverDate, localDate) {
|
|
559
|
+
const fmt = (d) => d instanceof Date && !isNaN(d)
|
|
560
|
+
? d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
561
|
+
: null;
|
|
562
|
+
const s = fmt(serverDate);
|
|
563
|
+
const l = fmt(localDate);
|
|
564
|
+
if (s && l) return `\n server: ${s} | local: ${l}`;
|
|
565
|
+
if (s) return `\n server: ${s}`;
|
|
566
|
+
return '';
|
|
516
567
|
}
|
|
517
568
|
|
|
518
569
|
// Sync version for message building (cached)
|
|
@@ -524,31 +575,36 @@ function loadUserInfoSync() {
|
|
|
524
575
|
/**
|
|
525
576
|
* Prompt the user when a record has changed.
|
|
526
577
|
* options.localIsNewer: when true, the local file has modifications not on server.
|
|
578
|
+
* options.diffable: when false, omit the "Compare differences" choice.
|
|
579
|
+
* options.serverDate / options.localDate: Date objects shown as hints.
|
|
527
580
|
* Returns: 'overwrite' | 'compare' | 'skip' | 'overwrite_all' | 'skip_all'
|
|
528
581
|
*/
|
|
529
582
|
export async function promptChangeDetection(recordName, serverRecord, config, options = {}) {
|
|
530
583
|
const localIsNewer = options.localIsNewer || false;
|
|
584
|
+
const diffable = options.diffable !== false; // default true for text records
|
|
531
585
|
|
|
532
586
|
// Cache user info for message building
|
|
533
587
|
_cachedUserInfo = await loadUserInfo();
|
|
534
588
|
|
|
589
|
+
const datePart = formatDateHint(options.serverDate, options.localDate);
|
|
590
|
+
|
|
535
591
|
const message = localIsNewer
|
|
536
|
-
? `"${recordName}" has local changes not on the server`
|
|
537
|
-
: buildChangeMessage(recordName, serverRecord, config);
|
|
592
|
+
? `"${recordName}" has local changes not on the server${datePart}`
|
|
593
|
+
: buildChangeMessage(recordName, serverRecord, config, options);
|
|
538
594
|
|
|
539
595
|
const inquirer = (await import('inquirer')).default;
|
|
540
596
|
|
|
541
597
|
const choices = localIsNewer
|
|
542
598
|
? [
|
|
543
599
|
{ name: 'Restore server version (discard local changes)', value: 'overwrite' },
|
|
544
|
-
{ name: 'Compare differences (dbo diff)', value: 'compare' },
|
|
600
|
+
...(diffable ? [{ name: 'Compare differences (dbo diff)', value: 'compare' }] : []),
|
|
545
601
|
{ name: 'Keep local changes', value: 'skip' },
|
|
546
602
|
{ name: 'Restore all to server version', value: 'overwrite_all' },
|
|
547
603
|
{ name: 'Keep all local changes', value: 'skip_all' },
|
|
548
604
|
]
|
|
549
605
|
: [
|
|
550
606
|
{ name: 'Overwrite local file with server version', value: 'overwrite' },
|
|
551
|
-
{ name: 'Compare differences (dbo diff)', value: 'compare' },
|
|
607
|
+
...(diffable ? [{ name: 'Compare differences (dbo diff)', value: 'compare' }] : []),
|
|
552
608
|
{ name: 'Skip this file', value: 'skip' },
|
|
553
609
|
{ name: 'Overwrite this and all remaining changed files', value: 'overwrite_all' },
|
|
554
610
|
{ name: 'Skip all remaining changed files', value: 'skip_all' },
|