@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.
@@ -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 IGNORED = new Set(['.dbo', '.claude', '.idea', '.vscode']);
135
- const isEmpty = entries.every(e => IGNORED.has(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
  }
@@ -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
 
@@ -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')
@@ -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 metaFiles = await findMetadataFiles(dirPath);
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 = join(dirname(metaPath), ref.substring(1));
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 ticketCheck = await checkStoredTicket(options);
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 = join(metaDir, refFile);
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
- if (dataExprs.length === 0) {
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 body = await buildInputBody(dataExprs, extraParams);
404
- let result = await client.postUrlEncoded('/api/input/submit', body);
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
- // Reactive ModifyKey retry server rejected because key wasn't set locally
407
- if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
408
- const retryMK = await handleModifyKeyError();
409
- if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
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
- body = await buildInputBody(dataExprs, extraParams);
437
- result = await client.postUrlEncoded('/api/input/submit', body);
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 = join(dirname(metaPath), refFile);
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
- // Skip hidden dirs, node_modules, .dbo
42
- if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
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
- results.push(fullPath);
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
- results.push(fullPath);
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
- return `"${recordName}" was updated on server by you (from another session)`;
542
+ who = 'you (from another session)';
512
543
  } else if (updatedBy) {
513
- return `"${recordName}" was updated on server by user ${updatedBy}`;
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' },