@dboio/cli 0.4.2 → 0.6.0

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.
@@ -1,6 +1,7 @@
1
1
  import { writeFile, mkdir, access, readFile } from 'fs/promises';
2
2
  import { join, dirname, basename, extname } from 'path';
3
3
  import { log } from './logger.js';
4
+ import { setFileTimestamps } from './timestamps.js';
4
5
 
5
6
  /**
6
7
  * Resolve a column value for file writing.
@@ -32,6 +33,8 @@ function resolveContentValue(value) {
32
33
  * saveExtension: column name for extension, or literal extension
33
34
  * nonInteractive: skip all prompts
34
35
  * contentFileMap: object mapping column → { suffix, extension } per column
36
+ * changeDetection: enable smart change detection (compares timestamps)
37
+ * config: loaded config object (needed for ServerTimezone)
35
38
  */
36
39
  export async function saveToDisk(rows, columns, options = {}) {
37
40
  if (!rows || rows.length === 0) {
@@ -158,7 +161,14 @@ export async function saveToDisk(rows, columns, options = {}) {
158
161
  extensionSource = useExtCol ? extCol : null;
159
162
  }
160
163
 
164
+ // Lazy-load change detection functions only if needed
165
+ let diffModule = null;
166
+ if (options.changeDetection) {
167
+ diffModule = await import('./diff.js');
168
+ }
169
+
161
170
  let savedCount = 0;
171
+ let bulkAction = null; // Track 'overwrite_all' or 'skip_all' across records
162
172
  const usedNames = new Map(); // track dir+name to avoid collisions
163
173
 
164
174
  for (const row of rows) {
@@ -210,10 +220,53 @@ export async function saveToDisk(rows, columns, options = {}) {
210
220
  } catch { /* ignore parse errors */ }
211
221
  }
212
222
 
223
+ // ── Change detection ────────────────────────────────────────────────
224
+ if (options.changeDetection && existingMeta && !options.nonInteractive && diffModule) {
225
+ // Handle bulk actions from previous iterations
226
+ if (bulkAction === 'skip_all') {
227
+ log.dim(` Skipped ${finalName}`);
228
+ continue;
229
+ }
230
+
231
+ if (bulkAction !== 'overwrite_all') {
232
+ const localSyncTime = await diffModule.getLocalSyncTime(metaPath);
233
+ const serverIsNewer = diffModule.isServerNewer(localSyncTime, row._LastUpdated, options.config || {});
234
+
235
+ if (serverIsNewer) {
236
+ const action = await diffModule.promptChangeDetection(finalName, row, options.config || {});
237
+
238
+ if (action === 'skip') {
239
+ log.dim(` Skipped ${finalName}`);
240
+ continue;
241
+ }
242
+ if (action === 'skip_all') {
243
+ bulkAction = 'skip_all';
244
+ log.dim(` Skipped ${finalName}`);
245
+ continue;
246
+ }
247
+ if (action === 'overwrite_all') {
248
+ bulkAction = 'overwrite_all';
249
+ // Fall through to write
250
+ }
251
+ if (action === 'compare') {
252
+ // Run inline diff and merge
253
+ const mergeResult = await diffModule.inlineDiffAndMerge(row, metaPath, options.config || {});
254
+ if (!mergeResult.skipped) {
255
+ savedCount++;
256
+ }
257
+ continue; // inlineDiffAndMerge handles writing
258
+ }
259
+ // 'overwrite' falls through to normal write
260
+ }
261
+ }
262
+ }
263
+
264
+ // ── Write content files ─────────────────────────────────────────────
213
265
  // Build metadata with @filename placeholders
214
266
  const meta = { ...row };
215
267
  if (options.entity) meta._entity = options.entity;
216
268
  const contentColumnsList = [];
269
+ const writtenFilePaths = []; // Track files for timestamp setting
217
270
 
218
271
  for (const col of contentCols) {
219
272
  const content = row[col];
@@ -235,8 +288,8 @@ export async function saveToDisk(rows, columns, options = {}) {
235
288
 
236
289
  const filePath = join(dir, fileName);
237
290
 
238
- // Prompt before overwriting
239
- if (await fileExists(filePath) && !options.nonInteractive) {
291
+ // Prompt before overwriting (only if NOT using change detection — change detection handles its own prompts)
292
+ if (!options.changeDetection && await fileExists(filePath) && !options.nonInteractive) {
240
293
  const inquirer = (await import('inquirer')).default;
241
294
  const { overwrite } = await inquirer.prompt([{
242
295
  type: 'confirm', name: 'overwrite',
@@ -253,6 +306,7 @@ export async function saveToDisk(rows, columns, options = {}) {
253
306
 
254
307
  await writeFile(filePath, resolveContentValue(content));
255
308
  log.success(`Saved ${filePath}`);
309
+ writtenFilePaths.push(filePath);
256
310
  savedCount++;
257
311
 
258
312
  meta[col] = `@${fileName}`;
@@ -263,8 +317,9 @@ export async function saveToDisk(rows, columns, options = {}) {
263
317
  meta._contentColumns = contentColumnsList;
264
318
  }
265
319
 
266
- // Save metadata — prompt if exists
267
- if (await fileExists(metaPath) && !options.nonInteractive) {
320
+ // ── Save metadata ───────────────────────────────────────────────────
321
+ // Prompt before overwriting (only if NOT using change detection)
322
+ if (!options.changeDetection && await fileExists(metaPath) && !options.nonInteractive) {
268
323
  const inquirer = (await import('inquirer')).default;
269
324
  const { overwrite } = await inquirer.prompt([{
270
325
  type: 'confirm', name: 'overwrite',
@@ -279,7 +334,19 @@ export async function saveToDisk(rows, columns, options = {}) {
279
334
 
280
335
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
281
336
  log.dim(` → ${metaPath}`);
337
+ writtenFilePaths.push(metaPath);
282
338
  savedCount++;
339
+
340
+ // ── Set file timestamps ─────────────────────────────────────────────
341
+ // Sync file mtime to server's _LastUpdated (establishes sync point)
342
+ const serverTz = options.config?.ServerTimezone;
343
+ if (serverTz && (row._CreatedOn || row._LastUpdated)) {
344
+ for (const fp of writtenFilePaths) {
345
+ try {
346
+ await setFileTimestamps(fp, row._CreatedOn, row._LastUpdated, serverTz);
347
+ } catch { /* non-critical */ }
348
+ }
349
+ }
283
350
  }
284
351
 
285
352
  log.info(`Saved ${savedCount} file(s) for ${rows.length} record(s)`);
@@ -9,13 +9,27 @@ export const BINS_DIR = 'Bins';
9
9
  /** Default top-level directories created at project root during clone */
10
10
  export const DEFAULT_PROJECT_DIRS = [
11
11
  BINS_DIR,
12
+ 'Automations',
12
13
  'App Versions',
13
14
  'Documentation',
14
15
  'Sites',
15
16
  'Extensions',
16
17
  'Data Sources',
18
+ 'Groups',
19
+ 'Integrations',
17
20
  ];
18
21
 
22
+ /** Map from server entity key → local project directory name */
23
+ export const ENTITY_DIR_MAP = {
24
+ extension: 'Extensions',
25
+ app_version: 'App Versions',
26
+ data_source: 'Data Sources',
27
+ site: 'Sites',
28
+ group: 'Groups',
29
+ integration: 'Integrations',
30
+ automation: 'Automations',
31
+ };
32
+
19
33
  /**
20
34
  * Build a bin hierarchy from an array of bin objects.
21
35
  * Filters by targetAppId and resolves full directory paths via ParentBinID traversal.
@@ -127,3 +141,25 @@ export function getBinName(binId, structure) {
127
141
  const entry = structure[binId];
128
142
  return entry ? entry.name : null;
129
143
  }
144
+
145
+ /**
146
+ * Reverse-lookup: find a bin entry by its directory path.
147
+ * Accepts paths with or without the "Bins/" prefix.
148
+ * Returns { binId, name, path, segment, parentBinID, uid, fullPath } or null.
149
+ */
150
+ export function findBinByPath(dirPath, structure) {
151
+ const normalized = dirPath.replace(/^Bins\//, '').replace(/^\/+|\/+$/g, '');
152
+ for (const [binId, entry] of Object.entries(structure)) {
153
+ if (entry.fullPath === normalized) {
154
+ return { binId: Number(binId), ...entry };
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * Find all direct child bins of a given BinID.
162
+ */
163
+ export function findChildBins(binId, structure) {
164
+ return Object.values(structure).filter(e => e.parentBinID === binId);
165
+ }
@@ -33,10 +33,11 @@ Here are the available commands:
33
33
  | push | Push local files back to DBO.io |
34
34
  | add | Add a new file to DBO.io |
35
35
  | clone | Clone an app to local project structure |
36
+ | diff | Compare local files with server versions |
37
+ | rm | Remove a file and stage server deletion |
36
38
  | deploy | Deploy via manifest |
37
39
  | cache | Manage cache |
38
- | install | Install components |
39
- | update | Update CLI or plugins |
40
+ | install | Install or upgrade CLI, plugins, Claude commands (shorthand: `i`) |
40
41
 
41
42
  Just tell me what you'd like to do and I'll help you build the right command!
42
43
 
@@ -71,9 +72,36 @@ Available subcommands:
71
72
  - `cache list|refresh` — Manage cache
72
73
  - `clone [source]` — Clone an app to local project (from file or server)
73
74
  - `clone --app <name>` — Clone by app short name from server
75
+ - `diff [path]` — Compare local files against server and selectively merge changes
76
+ - `diff -y` — Accept all server changes without prompting
77
+ - `diff --no-interactive` — Show diffs without prompting to accept
78
+ - `rm <file>` — Remove a file locally and stage server deletion for next push
79
+ - `rm <directory>` — Remove a directory, all files, and sub-directories recursively
80
+ - `rm -f <path>` — Remove without confirmation prompts
81
+ - `rm --keep-local <path>` — Stage server deletions without deleting local files/directories
74
82
  - `deploy [name]` — Deploy via dbo.deploy.json manifest
75
- - `install` — Install components (claudecode, claudecommands)
76
- - `update` — Update CLI, plugins, or Claude commands
83
+ - `install` (alias: `i`) — Install or upgrade CLI, plugins, or Claude commands
84
+ - `i dbo` or `i dbo@latest` Install/upgrade the CLI from npm
85
+ - `i dbo@0.4.1` — Install a specific CLI version
86
+ - `install /path/to/src` — Install CLI from local source
87
+ - `install plugins` — Install/upgrade Claude command plugins
88
+ - `install plugins --global` — Install plugins to `~/.claude/commands/` (shared across projects)
89
+ - `install plugins --local` — Install plugins to `.claude/commands/` (project only)
90
+ - `install claudecommands` — Install/upgrade Claude Code commands
91
+ - `install claudecode` — Install Claude Code CLI + commands
92
+ - `install --claudecommand dbo --global` — Install a specific command globally
93
+
94
+ ## Change Detection (pull, clone, diff)
95
+
96
+ When pulling or cloning records that already exist locally, the CLI compares file modification times against the server's `_LastUpdated` timestamp. If the server has newer data, you'll be prompted with options:
97
+
98
+ 1. **Overwrite** — Replace local files with server version
99
+ 2. **Compare** — Show a line-by-line diff and selectively merge
100
+ 3. **Skip** — Keep local files unchanged
101
+ 4. **Overwrite all** — Accept all remaining server changes
102
+ 5. **Skip all** — Skip all remaining files
103
+
104
+ Use `dbo diff [path]` to compare without pulling. Use `-y` to auto-accept all changes.
77
105
 
78
106
  ## Smart Command Building
79
107
 
@@ -91,6 +119,8 @@ When helping the user build a command interactively:
91
119
  - **"I want to deploy/update a file"** → Check if `.metadata.json` exists → `dbo push` or `dbo content deploy`
92
120
  - **"I want to add a new file"** → `dbo add <path>` (will create metadata interactively)
93
121
  - **"I want to pull files from the server"** → `dbo pull` or `dbo pull -e <entity>`
122
+ - **"I want to delete/remove a file"** → `dbo rm <file>` (stages deletion for next `dbo push`)
123
+ - **"I want to delete a directory"** → `dbo rm <directory>` (removes all files + sub-dirs, stages bin deletions)
94
124
  - **"I want to see what's on the server"** → `dbo output -e <entity> --format json`
95
125
  - **"I need to set up this project"** → `dbo init` → `dbo login` → `dbo status`
96
126
  - **"I want to clone an app"** → `dbo clone --app <name>` or `dbo clone <local.json>`
@@ -211,8 +241,9 @@ Flags: `--app <name>`, `--domain <host>`, `-y/--yes`, `-v/--verbose`
211
241
  4. Creates directory structure from `children.bin` hierarchy → saves `.dbo/structure.json`
212
242
  5. Writes content files (decodes base64) with `*.metadata.json` into bin directories
213
243
  6. Downloads media files from server via `/api/media/{uid}` with `*.metadata.json`
214
- 7. Processes other entities with BinID into corresponding directories
215
- 8. Saves `app.json` to project root with `@path/to/*.metadata.json` references
244
+ 7. Processes entity-dir records (`extension`, `app_version`, `data_source`, `site`, `group`, `integration`, `automation`) into project directories (`Extensions/`, `Data Sources/`, etc.) as `.metadata.json` files with optional companion content files
245
+ 8. Processes remaining entities with BinID into corresponding bin directories
246
+ 9. Saves `app.json` to project root with `@path/to/*.metadata.json` references
216
247
 
217
248
  ### Placement preferences
218
249
 
@@ -1,168 +0,0 @@
1
- import { Command } from 'commander';
2
- import { readdir, readFile, copyFile, access, mkdir } from 'fs/promises';
3
- import { join, dirname } from 'path';
4
- import { fileURLToPath } from 'url';
5
- import { execSync } from 'child_process';
6
- import { createHash } from 'crypto';
7
- import { installClaudeCommands } from './install.js';
8
- import { log } from '../lib/logger.js';
9
-
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
- const CLI_ROOT = join(__dirname, '..', '..');
12
- const PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
13
-
14
- async function fileExists(path) {
15
- try { await access(path); return true; } catch { return false; }
16
- }
17
-
18
- function fileHash(content) {
19
- return createHash('md5').update(content).digest('hex');
20
- }
21
-
22
- export const updateCommand = new Command('update')
23
- .description('Update dbo-cli, plugins, or Claude Code commands')
24
- .argument('[target]', 'What to update: cli, plugins, claudecommands')
25
- .option('--claudecommand <name>', 'Update a specific Claude command by name')
26
- .action(async (target, options) => {
27
- try {
28
- if (options.claudecommand) {
29
- await updateSpecificCommand(options.claudecommand);
30
- } else if (target === 'cli') {
31
- await updateCli();
32
- } else if (target === 'plugins') {
33
- await updatePlugins();
34
- } else if (target === 'claudecommands') {
35
- await updateClaudeCommands();
36
- } else {
37
- // No target: update CLI + ask about claude commands
38
- await updateCli();
39
- const inquirer = (await import('inquirer')).default;
40
- const { updateCmds } = await inquirer.prompt([{
41
- type: 'confirm', name: 'updateCmds',
42
- message: 'Also update Claude Code commands?',
43
- default: true,
44
- }]);
45
- if (updateCmds) await updateClaudeCommands();
46
- }
47
- } catch (err) {
48
- log.error(err.message);
49
- process.exit(1);
50
- }
51
- });
52
-
53
- async function updateCli() {
54
- // Detect install method: git repo or npm global
55
- const isGitRepo = await fileExists(join(CLI_ROOT, '.git'));
56
- const isInNodeModules = CLI_ROOT.includes('node_modules');
57
-
58
- if (isGitRepo && !isInNodeModules) {
59
- log.info('Updating dbo-cli from git...');
60
- try {
61
- execSync('git pull', { cwd: CLI_ROOT, stdio: 'inherit' });
62
- execSync('npm install', { cwd: CLI_ROOT, stdio: 'inherit' });
63
- log.success('dbo-cli updated from git');
64
- } catch (err) {
65
- log.error(`Git update failed: ${err.message}`);
66
- }
67
- } else {
68
- log.info('Updating dbo-cli via npm...');
69
- try {
70
- execSync('npm update -g @dboio/cli', { stdio: 'inherit' });
71
- log.success('dbo-cli updated via npm');
72
- } catch (err) {
73
- log.error(`npm update failed: ${err.message}`);
74
- }
75
- }
76
-
77
- // Show current version
78
- try {
79
- const pkg = JSON.parse(await readFile(join(CLI_ROOT, 'package.json'), 'utf8'));
80
- log.label('Version', pkg.version);
81
- } catch { /* ignore */ }
82
- }
83
-
84
- async function updatePlugins() {
85
- log.info('Updating all plugins...');
86
- await updateClaudeCommands();
87
- }
88
-
89
- async function updateClaudeCommands() {
90
- const commandsDir = join(process.cwd(), '.claude', 'commands');
91
-
92
- if (!await fileExists(commandsDir)) {
93
- log.info('No .claude/commands/ directory found. Installing...');
94
- await installClaudeCommands();
95
- return;
96
- }
97
-
98
- let pluginFiles;
99
- try {
100
- pluginFiles = (await readdir(PLUGINS_DIR)).filter(f => f.endsWith('.md'));
101
- } catch {
102
- log.error(`Plugin source not found: ${PLUGINS_DIR}`);
103
- return;
104
- }
105
-
106
- let updated = 0;
107
- let upToDate = 0;
108
-
109
- for (const file of pluginFiles) {
110
- const srcPath = join(PLUGINS_DIR, file);
111
- const destPath = join(commandsDir, file);
112
-
113
- const srcContent = await readFile(srcPath, 'utf8');
114
-
115
- if (await fileExists(destPath)) {
116
- const destContent = await readFile(destPath, 'utf8');
117
- if (fileHash(srcContent) === fileHash(destContent)) {
118
- upToDate++;
119
- continue;
120
- }
121
- await copyFile(srcPath, destPath);
122
- log.success(`Updated .claude/commands/${file}`);
123
- updated++;
124
- } else {
125
- await copyFile(srcPath, destPath);
126
- log.success(`Installed .claude/commands/${file} (new)`);
127
- updated++;
128
- }
129
- }
130
-
131
- if (updated > 0) {
132
- log.info(`${updated} command(s) updated`);
133
- log.warn('Note: Updated commands will be available in new Claude Code sessions (restart any active session).');
134
- }
135
- if (upToDate > 0) {
136
- log.dim(`${upToDate} command(s) already up to date`);
137
- }
138
- }
139
-
140
- async function updateSpecificCommand(name) {
141
- const fileName = name.endsWith('.md') ? name : `${name}.md`;
142
- const srcPath = join(PLUGINS_DIR, fileName);
143
- const destPath = join(process.cwd(), '.claude', 'commands', fileName);
144
-
145
- if (!await fileExists(srcPath)) {
146
- log.error(`Command plugin "${name}" not found in source`);
147
- const available = (await readdir(PLUGINS_DIR)).filter(f => f.endsWith('.md')).map(f => f.replace('.md', ''));
148
- if (available.length > 0) log.dim(` Available: ${available.join(', ')}`);
149
- return;
150
- }
151
-
152
- if (!await fileExists(destPath)) {
153
- log.warn(`"${fileName}" not installed. Run "dbo install --claudecommand ${name}" first.`);
154
- return;
155
- }
156
-
157
- const srcContent = await readFile(srcPath, 'utf8');
158
- const destContent = await readFile(destPath, 'utf8');
159
-
160
- if (fileHash(srcContent) === fileHash(destContent)) {
161
- log.success(`${fileName} is already up to date`);
162
- return;
163
- }
164
-
165
- await copyFile(srcPath, destPath);
166
- log.success(`Updated .claude/commands/${fileName}`);
167
- log.warn('Note: Updated commands will be available in new Claude Code sessions (restart any active session).');
168
- }