@dboio/cli 0.4.1 → 0.5.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.
@@ -0,0 +1,246 @@
1
+ import { Command } from 'commander';
2
+ import { stat } from 'fs/promises';
3
+ import { join, dirname, basename, extname } from 'path';
4
+ import chalk from 'chalk';
5
+ import { DboClient } from '../lib/client.js';
6
+ import { loadConfig } from '../lib/config.js';
7
+ import { formatError } from '../lib/formatter.js';
8
+ import { log } from '../lib/logger.js';
9
+ import {
10
+ compareRecord,
11
+ findMetadataFiles,
12
+ formatDiff,
13
+ applyServerChanges,
14
+ } from '../lib/diff.js';
15
+
16
+ export const diffCommand = new Command('diff')
17
+ .description('Compare local files with server versions and selectively merge changes')
18
+ .argument('[path]', 'File or directory to diff (default: current directory)', '.')
19
+ .option('--no-interactive', 'Show diffs without prompting to accept')
20
+ .option('-y, --yes', 'Accept all server changes without prompting')
21
+ .option('-v, --verbose', 'Show HTTP request details')
22
+ .option('--domain <host>', 'Override domain')
23
+ .action(async (targetPath, options) => {
24
+ try {
25
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
26
+ const config = await loadConfig();
27
+
28
+ // Resolve target to metadata file(s)
29
+ const metaFiles = await resolveTargetToMetaFiles(targetPath);
30
+
31
+ if (metaFiles.length === 0) {
32
+ log.warn('No .metadata.json files found.');
33
+ log.dim('Pull records first with "dbo pull" or "dbo clone".');
34
+ return;
35
+ }
36
+
37
+ log.info(`Comparing ${metaFiles.length} record(s) against server...`);
38
+
39
+ let updated = 0;
40
+ let skipped = 0;
41
+ let unchanged = 0;
42
+ let errors = 0;
43
+ let bulkAction = null;
44
+
45
+ for (const metaPath of metaFiles) {
46
+ const metaBase = basename(metaPath, '.metadata.json');
47
+
48
+ const result = await compareRecord(metaPath, client, config);
49
+
50
+ if (result.error) {
51
+ log.warn(`${metaBase}: ${result.error}`);
52
+ errors++;
53
+ continue;
54
+ }
55
+
56
+ if (!result.hasChanges) {
57
+ log.dim(`${metaBase}: no changes`);
58
+ unchanged++;
59
+ continue;
60
+ }
61
+
62
+ // Count content vs metadata changes
63
+ const contentChanges = result.fieldDiffs.filter(d => d.isContentFile).length;
64
+ const metaChanges = result.fieldDiffs.filter(d => !d.isContentFile).length;
65
+ log.info(`${metaBase}: ${result.fieldDiffs.length} difference(s) (${contentChanges} content, ${metaChanges} metadata)`);
66
+
67
+ if (options.yes || bulkAction === 'accept_all') {
68
+ // Auto-accept all
69
+ const allFields = new Set(result.fieldDiffs.map(d => d.column));
70
+ await applyServerChanges(result, allFields, config);
71
+ log.success(`Updated ${metaBase}`);
72
+ updated++;
73
+ continue;
74
+ }
75
+
76
+ if (bulkAction === 'skip_all') {
77
+ log.dim(`Skipped ${metaBase}`);
78
+ skipped++;
79
+ continue;
80
+ }
81
+
82
+ // Display diffs
83
+ for (const fd of result.fieldDiffs) {
84
+ log.plain('');
85
+ if (fd.isContentFile && fd.diff) {
86
+ log.label('Field', `${fd.column} (${fd.localFilePath})`);
87
+ const formatted = formatDiff(fd.diff, {
88
+ localLabel: `local: ${fd.localFilePath}`,
89
+ serverLabel: `server: ${result.entity}:${result.uid}`,
90
+ });
91
+ for (const line of formatted) log.plain(line);
92
+ } else {
93
+ log.label('Field', fd.column);
94
+ if (fd.localValue) log.plain(chalk.red(` - ${fd.localValue}`));
95
+ if (fd.serverValue) log.plain(chalk.green(` + ${fd.serverValue}`));
96
+ }
97
+ }
98
+
99
+ if (!options.interactive) {
100
+ // Display-only mode
101
+ skipped++;
102
+ continue;
103
+ }
104
+
105
+ // Interactive: prompt for acceptance
106
+ log.plain('');
107
+ const inquirer = (await import('inquirer')).default;
108
+ const { action } = await inquirer.prompt([{
109
+ type: 'list',
110
+ name: 'action',
111
+ message: `Accept server changes for "${metaBase}"?`,
112
+ choices: [
113
+ { name: 'Accept all changes for this file', value: 'accept' },
114
+ { name: 'Cherry-pick individual fields', value: 'cherry_pick' },
115
+ { name: 'Skip this file', value: 'skip' },
116
+ { name: 'Accept all remaining files', value: 'accept_all' },
117
+ { name: 'Skip all remaining files', value: 'skip_all' },
118
+ ],
119
+ }]);
120
+
121
+ if (action === 'accept' || action === 'accept_all') {
122
+ const allFields = new Set(result.fieldDiffs.map(d => d.column));
123
+ await applyServerChanges(result, allFields, config);
124
+ log.success(`Updated ${metaBase}`);
125
+ updated++;
126
+ if (action === 'accept_all') bulkAction = 'accept_all';
127
+ } else if (action === 'cherry_pick') {
128
+ const accepted = await cherryPickFields(result, inquirer);
129
+ if (accepted.size > 0) {
130
+ await applyServerChanges(result, accepted, config);
131
+ log.success(`Updated ${metaBase} (${accepted.size} field(s))`);
132
+ updated++;
133
+ } else {
134
+ skipped++;
135
+ }
136
+ } else if (action === 'skip_all') {
137
+ bulkAction = 'skip_all';
138
+ skipped++;
139
+ } else {
140
+ skipped++;
141
+ }
142
+ }
143
+
144
+ // Summary
145
+ log.plain('');
146
+ const parts = [];
147
+ if (updated > 0) parts.push(`${updated} updated`);
148
+ if (skipped > 0) parts.push(`${skipped} skipped`);
149
+ if (unchanged > 0) parts.push(`${unchanged} unchanged`);
150
+ if (errors > 0) parts.push(`${errors} error(s)`);
151
+ log.info(`Diff complete: ${parts.join(', ')}`);
152
+
153
+ } catch (err) {
154
+ formatError(err);
155
+ process.exit(1);
156
+ }
157
+ });
158
+
159
+ /**
160
+ * Resolve a target path to an array of .metadata.json file paths.
161
+ */
162
+ async function resolveTargetToMetaFiles(targetPath) {
163
+ let pathStat;
164
+ try {
165
+ pathStat = await stat(targetPath);
166
+ } catch {
167
+ // Maybe it's a file without extension — try finding metadata
168
+ const metaPath = targetPath.endsWith('.metadata.json')
169
+ ? targetPath
170
+ : `${targetPath}.metadata.json`;
171
+
172
+ try {
173
+ await stat(metaPath);
174
+ return [metaPath];
175
+ } catch {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ if (pathStat.isDirectory()) {
181
+ return findMetadataFiles(targetPath);
182
+ }
183
+
184
+ // Single file — find its companion metadata
185
+ if (targetPath.endsWith('.metadata.json')) {
186
+ return [targetPath];
187
+ }
188
+
189
+ // Content file: colors.css → colors.metadata.json
190
+ const dir = dirname(targetPath);
191
+ const base = basename(targetPath, extname(targetPath));
192
+ const metaPath = join(dir, `${base}.metadata.json`);
193
+
194
+ try {
195
+ await stat(metaPath);
196
+ return [metaPath];
197
+ } catch {
198
+ // Try with full filename for media: logo.png → logo.png.metadata.json
199
+ const mediaMetaPath = `${targetPath}.metadata.json`;
200
+ try {
201
+ await stat(mediaMetaPath);
202
+ return [mediaMetaPath];
203
+ } catch {
204
+ log.warn(`No metadata found for "${targetPath}"`);
205
+ return [];
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Cherry-pick individual fields from a diff result.
212
+ * Returns Set of accepted column names.
213
+ */
214
+ async function cherryPickFields(result, inquirer) {
215
+ const accepted = new Set();
216
+ let bulkAction = null;
217
+
218
+ for (const fd of result.fieldDiffs) {
219
+ if (bulkAction === 'accept_all') {
220
+ accepted.add(fd.column);
221
+ continue;
222
+ }
223
+ if (bulkAction === 'skip_all') continue;
224
+
225
+ const { action } = await inquirer.prompt([{
226
+ type: 'list',
227
+ name: 'action',
228
+ message: `Accept server change for "${fd.column}"?`,
229
+ choices: [
230
+ { name: 'Accept', value: 'accept' },
231
+ { name: 'Skip', value: 'skip' },
232
+ { name: 'Accept all remaining', value: 'accept_all' },
233
+ { name: 'Skip all remaining', value: 'skip_all' },
234
+ ],
235
+ }]);
236
+
237
+ if (action === 'accept' || action === 'accept_all') {
238
+ accepted.add(fd.column);
239
+ }
240
+ if (action === 'accept_all' || action === 'skip_all') {
241
+ bulkAction = action;
242
+ }
243
+ }
244
+
245
+ return accepted;
246
+ }
@@ -2,17 +2,23 @@ import { Command } from 'commander';
2
2
  import { access } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore } from '../lib/config.js';
5
- import { installClaudeCommands } from './install.js';
5
+ import { installOrUpdateClaudeCommands } from './install.js';
6
6
  import { log } from '../lib/logger.js';
7
7
 
8
8
  export const initCommand = new Command('init')
9
9
  .description('Initialize DBO CLI configuration for the current directory')
10
- .option('--domain <host>', 'DBO instance domain (e.g., beta-dev_model.dbo.io)')
10
+ .option('--domain <host>', 'DBO instance domain (e.g., my-domain.com)')
11
11
  .option('--username <user>', 'DBO username')
12
12
  .option('--force', 'Overwrite existing configuration')
13
13
  .option('--app <shortName>', 'App short name (for clone)')
14
14
  .option('--clone', 'Clone the app after initialization')
15
+ .option('-y, --yes', 'Skip all interactive prompts (alias for --non-interactive)')
16
+ .option('--non-interactive', 'Skip all interactive prompts')
17
+ .option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
18
+ .option('--local', 'Install Claude commands to project directory (.claude/commands/)')
15
19
  .action(async (options) => {
20
+ // Merge --yes into nonInteractive
21
+ if (options.yes) options.nonInteractive = true;
16
22
  try {
17
23
  if (await isInitialized() && !options.force) {
18
24
  log.warn('Already initialized. Use --force to overwrite.');
@@ -23,7 +29,7 @@ export const initCommand = new Command('init')
23
29
  let username = options.username;
24
30
 
25
31
  // Check for legacy config to offer migration
26
- if (!domain && await hasLegacyConfig()) {
32
+ if (!options.nonInteractive && !domain && await hasLegacyConfig()) {
27
33
  const legacy = await readLegacyConfig();
28
34
  log.info('Legacy configuration detected (.domain, .username, .password)');
29
35
  const inquirer = (await import('inquirer')).default;
@@ -56,7 +62,7 @@ export const initCommand = new Command('init')
56
62
  }
57
63
 
58
64
  // Ensure sensitive files are gitignored
59
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt']);
65
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json']);
60
66
 
61
67
  log.success(`Initialized .dbo/ for ${domain}`);
62
68
  log.dim(' Run "dbo login" to authenticate.');
@@ -77,26 +83,28 @@ export const initCommand = new Command('init')
77
83
  await performClone(null, { app: appShortName, domain });
78
84
  }
79
85
 
80
- // Offer Claude Code integration
81
- const claudeDir = join(process.cwd(), '.claude');
82
- const inquirer = (await import('inquirer')).default;
83
- let hasClaudeDir = false;
84
- try { await access(claudeDir); hasClaudeDir = true; } catch {}
86
+ // Offer Claude Code integration (skip in non-interactive mode)
87
+ if (!options.nonInteractive) {
88
+ const claudeDir = join(process.cwd(), '.claude');
89
+ const inquirer = (await import('inquirer')).default;
90
+ let hasClaudeDir = false;
91
+ try { await access(claudeDir); hasClaudeDir = true; } catch {}
85
92
 
86
- if (hasClaudeDir) {
87
- const { installCmds } = await inquirer.prompt([{
88
- type: 'confirm', name: 'installCmds',
89
- message: 'Install DBO commands for Claude Code? (adds /dbo slash command)',
90
- default: true,
91
- }]);
92
- if (installCmds) await installClaudeCommands();
93
- } else {
94
- const { setupClaude } = await inquirer.prompt([{
95
- type: 'confirm', name: 'setupClaude',
96
- message: 'Set up Claude Code integration? (creates .claude/commands/)',
97
- default: false,
98
- }]);
99
- if (setupClaude) await installClaudeCommands();
93
+ if (hasClaudeDir) {
94
+ const { installCmds } = await inquirer.prompt([{
95
+ type: 'confirm', name: 'installCmds',
96
+ message: 'Install DBO commands for Claude Code? (adds /dbo slash command)',
97
+ default: true,
98
+ }]);
99
+ if (installCmds) await installOrUpdateClaudeCommands(options);
100
+ } else {
101
+ const { setupClaude } = await inquirer.prompt([{
102
+ type: 'confirm', name: 'setupClaude',
103
+ message: 'Set up Claude Code integration? (creates .claude/commands/)',
104
+ default: false,
105
+ }]);
106
+ if (setupClaude) await installOrUpdateClaudeCommands(options);
107
+ }
100
108
  }
101
109
  } catch (err) {
102
110
  log.error(err.message);