@dboio/cli 0.4.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,309 @@
1
+ import { Command } from 'commander';
2
+ import { readFile, readdir, stat, writeFile } from 'fs/promises';
3
+ import { join, dirname, basename, extname, relative } from 'path';
4
+ import { DboClient } from '../lib/client.js';
5
+ import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
6
+ import { formatResponse, formatError } from '../lib/formatter.js';
7
+ import { log } from '../lib/logger.js';
8
+ import { shouldSkipColumn } from '../lib/columns.js';
9
+ import { loadConfig } from '../lib/config.js';
10
+ import { setFileTimestamps } from '../lib/timestamps.js';
11
+
12
+ export const pushCommand = new Command('push')
13
+ .description('Push local files back to DBO.io using metadata from pull')
14
+ .argument('<path>', 'File or directory to push')
15
+ .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
16
+ .option('--ticket <id>', 'Override ticket ID')
17
+ .option('--meta-only', 'Only push metadata changes, skip file content')
18
+ .option('--content-only', 'Only push file content, skip metadata columns')
19
+ .option('-y, --yes', 'Auto-accept all prompts (path refactoring, etc.)')
20
+ .option('--json', 'Output raw JSON')
21
+ .option('--jq <expr>', 'Filter JSON response')
22
+ .option('-v, --verbose', 'Show HTTP request details')
23
+ .option('--domain <host>', 'Override domain')
24
+ .action(async (targetPath, options) => {
25
+ try {
26
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
27
+ const pathStat = await stat(targetPath);
28
+
29
+ if (pathStat.isDirectory()) {
30
+ await pushDirectory(targetPath, client, options);
31
+ } else {
32
+ await pushSingleFile(targetPath, client, options);
33
+ }
34
+ } catch (err) {
35
+ formatError(err);
36
+ process.exit(1);
37
+ }
38
+ });
39
+
40
+ /**
41
+ * Push a single file using its companion .metadata.json
42
+ */
43
+ async function pushSingleFile(filePath, client, options) {
44
+ // Find the metadata file
45
+ const dir = dirname(filePath);
46
+ const base = basename(filePath, extname(filePath));
47
+ const metaPath = join(dir, `${base}.metadata.json`);
48
+
49
+ let meta;
50
+ try {
51
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
52
+ } catch {
53
+ log.error(`No metadata found at "${metaPath}". Pull the record first with "dbo content pull" or "dbo output --save".`);
54
+ process.exit(1);
55
+ }
56
+
57
+ await pushFromMetadata(meta, metaPath, client, options);
58
+ }
59
+
60
+ /**
61
+ * Push all records found in a directory (recursive)
62
+ */
63
+ async function pushDirectory(dirPath, client, options) {
64
+ const metaFiles = await findMetadataFiles(dirPath);
65
+
66
+ if (metaFiles.length === 0) {
67
+ log.warn(`No .metadata.json files found in "${dirPath}".`);
68
+ return;
69
+ }
70
+
71
+ log.info(`Found ${metaFiles.length} record(s) to push`);
72
+
73
+ let succeeded = 0;
74
+ let failed = 0;
75
+
76
+ for (const metaPath of metaFiles) {
77
+ let meta;
78
+ try {
79
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
80
+ } catch (err) {
81
+ log.warn(`Skipping invalid metadata: ${metaPath} (${err.message})`);
82
+ failed++;
83
+ continue;
84
+ }
85
+
86
+ if (!meta.UID && !meta._id) {
87
+ log.warn(`Skipping "${metaPath}": no UID or _id found`);
88
+ failed++;
89
+ continue;
90
+ }
91
+
92
+ if (!meta._entity) {
93
+ log.warn(`Skipping "${metaPath}": no _entity found`);
94
+ failed++;
95
+ continue;
96
+ }
97
+
98
+ // Verify @file references exist
99
+ const contentCols = meta._contentColumns || [];
100
+ let missingFiles = false;
101
+ for (const col of contentCols) {
102
+ const ref = meta[col];
103
+ if (ref && ref.startsWith('@')) {
104
+ const refPath = join(dirname(metaPath), ref.substring(1));
105
+ try {
106
+ await stat(refPath);
107
+ } catch {
108
+ log.warn(`Skipping "${metaPath}": referenced file "${ref}" not found at "${refPath}"`);
109
+ missingFiles = true;
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ if (missingFiles) { failed++; continue; }
115
+
116
+ try {
117
+ await pushFromMetadata(meta, metaPath, client, options);
118
+ succeeded++;
119
+ } catch (err) {
120
+ log.error(`Failed: ${metaPath} — ${err.message}`);
121
+ failed++;
122
+ }
123
+ }
124
+
125
+ log.info(`Push complete: ${succeeded} succeeded, ${failed} failed`);
126
+ }
127
+
128
+ /**
129
+ * Build and submit input expressions from a metadata object
130
+ */
131
+ async function pushFromMetadata(meta, metaPath, client, options) {
132
+ const uid = meta.UID || meta._id;
133
+ const entity = meta._entity;
134
+ const contentCols = new Set(meta._contentColumns || []);
135
+ const metaDir = dirname(metaPath);
136
+
137
+ if (!uid) {
138
+ throw new Error(`No UID found in ${metaPath}`);
139
+ }
140
+ if (!entity) {
141
+ throw new Error(`No _entity found in ${metaPath}`);
142
+ }
143
+
144
+ // Detect path mismatch
145
+ if (meta.Path) {
146
+ await checkPathMismatch(meta, metaPath, options);
147
+ }
148
+
149
+ const dataExprs = [];
150
+ let metaUpdated = false;
151
+
152
+ for (const [key, value] of Object.entries(meta)) {
153
+ if (shouldSkipColumn(key)) continue;
154
+ if (key === 'UID') continue; // UID is the identifier, not a column to update
155
+ if (value === null || value === undefined) continue;
156
+
157
+ const isContentCol = contentCols.has(key);
158
+
159
+ // --meta-only: skip content columns
160
+ if (options.metaOnly && isContentCol) continue;
161
+ // --content-only: skip non-content columns
162
+ if (options.contentOnly && !isContentCol) continue;
163
+
164
+ const strValue = String(value);
165
+
166
+ if (strValue.startsWith('@')) {
167
+ // @filename reference — resolve to actual file path
168
+ const refFile = strValue.substring(1);
169
+ const refPath = join(metaDir, refFile);
170
+ dataExprs.push(`RowUID:${uid};column:${entity}.${key}@${refPath}`);
171
+ } else {
172
+ dataExprs.push(`RowUID:${uid};column:${entity}.${key}=${strValue}`);
173
+ }
174
+ }
175
+
176
+ if (dataExprs.length === 0) {
177
+ log.warn(`Nothing to push for ${basename(metaPath)}`);
178
+ return;
179
+ }
180
+
181
+ log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) — ${dataExprs.length} field(s)`);
182
+
183
+ const extraParams = { '_confirm': options.confirm };
184
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
185
+
186
+ let body = await buildInputBody(dataExprs, extraParams);
187
+ let result = await client.postUrlEncoded('/api/input/submit', body);
188
+
189
+ // Retry with prompted params if needed (ticket, user)
190
+ const retryParams = await checkSubmitErrors(result);
191
+ if (retryParams) {
192
+ Object.assign(extraParams, retryParams);
193
+ body = await buildInputBody(dataExprs, extraParams);
194
+ result = await client.postUrlEncoded('/api/input/submit', body);
195
+ }
196
+
197
+ formatResponse(result, { json: options.json, jq: options.jq });
198
+
199
+ // Update metadata on disk if path was changed
200
+ if (metaUpdated) {
201
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
202
+ log.dim(` → Updated ${metaPath}`);
203
+ }
204
+
205
+ if (!result.successful) {
206
+ throw new Error('Push failed');
207
+ }
208
+
209
+ // Update file timestamps from server response
210
+ try {
211
+ const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
212
+ if (editResults.length > 0) {
213
+ const updated = editResults[0]._LastUpdated || editResults[0].LastUpdated;
214
+ if (updated) {
215
+ const config = await loadConfig();
216
+ const serverTz = config.ServerTimezone;
217
+ if (serverTz) {
218
+ // Update metadata _LastUpdated and set file mtime
219
+ meta._LastUpdated = updated;
220
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
221
+ await setFileTimestamps(metaPath, meta._CreatedOn, updated, serverTz);
222
+ // Update content file mtime too
223
+ const contentCols = meta._contentColumns || [];
224
+ for (const col of contentCols) {
225
+ const ref = meta[col];
226
+ if (ref && String(ref).startsWith('@')) {
227
+ const contentPath = join(dirname(metaPath), String(ref).substring(1));
228
+ await setFileTimestamps(contentPath, meta._CreatedOn, updated, serverTz);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+ } catch { /* non-critical timestamp update */ }
235
+ }
236
+
237
+ /**
238
+ * Detect if a file has been moved to a different directory than its metadata Path suggests
239
+ */
240
+ async function checkPathMismatch(meta, metaPath, options) {
241
+ const metaDir = dirname(metaPath);
242
+ const metaBase = basename(metaPath, '.metadata.json');
243
+
244
+ // Find the content file referenced by @filename
245
+ const contentCols = meta._contentColumns || [];
246
+ let contentFileName = null;
247
+ for (const col of contentCols) {
248
+ const ref = meta[col];
249
+ if (ref && ref.startsWith('@')) {
250
+ contentFileName = ref.substring(1);
251
+ break;
252
+ }
253
+ }
254
+
255
+ if (!contentFileName) return;
256
+
257
+ // Compute the current path based on where the file actually is
258
+ const currentFilePath = join(metaDir, contentFileName);
259
+ const currentRelPath = relative(process.cwd(), currentFilePath);
260
+
261
+ // Normalize stored path for comparison
262
+ const storedPath = String(meta.Path).replace(/^\/+|\/+$/g, '');
263
+ const currentPath = currentRelPath.replace(/\\/g, '/');
264
+
265
+ if (storedPath === currentPath) return;
266
+
267
+ // Path mismatch detected
268
+ log.warn(`Path mismatch for "${metaBase}":`);
269
+ log.label(' Metadata Path', storedPath);
270
+ log.label(' Current path ', currentPath);
271
+
272
+ let updatePath = options.yes;
273
+ if (!updatePath) {
274
+ const inquirer = (await import('inquirer')).default;
275
+ const { confirm } = await inquirer.prompt([{
276
+ type: 'confirm',
277
+ name: 'confirm',
278
+ message: `Update Path column to "${currentPath}"?`,
279
+ default: true,
280
+ }]);
281
+ updatePath = confirm;
282
+ }
283
+
284
+ if (updatePath) {
285
+ meta.Path = currentPath;
286
+ // Write updated metadata back to disk
287
+ await writeFile(join(dirname(metaPath), basename(metaPath)), JSON.stringify(meta, null, 2) + '\n');
288
+ log.success(` Path updated to "${currentPath}"`);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Recursively find all .metadata.json files in a directory
294
+ */
295
+ async function findMetadataFiles(dir) {
296
+ const results = [];
297
+ const entries = await readdir(dir, { withFileTypes: true });
298
+
299
+ for (const entry of entries) {
300
+ const fullPath = join(dir, entry.name);
301
+ if (entry.isDirectory()) {
302
+ results.push(...await findMetadataFiles(fullPath));
303
+ } else if (entry.name.endsWith('.metadata.json')) {
304
+ results.push(fullPath);
305
+ }
306
+ }
307
+
308
+ return results;
309
+ }
@@ -0,0 +1,41 @@
1
+ import { Command } from 'commander';
2
+ import { loadConfig, isInitialized, getActiveCookiesPath, loadUserInfo } from '../lib/config.js';
3
+ import { loadCookies } from '../lib/cookie-jar.js';
4
+ import { log } from '../lib/logger.js';
5
+
6
+ export const statusCommand = new Command('status')
7
+ .description('Show current DBO CLI configuration and session status')
8
+ .action(async () => {
9
+ try {
10
+ const initialized = await isInitialized();
11
+ const config = await loadConfig();
12
+
13
+ log.label('Initialized', initialized ? 'Yes (.dbo/)' : 'No');
14
+ log.label('Domain', config.domain || '(not set)');
15
+ log.label('Username', config.username || '(not set)');
16
+ const userInfo = await loadUserInfo();
17
+ log.label('User ID', userInfo.userId || '(not set)');
18
+ log.label('User UID', userInfo.userUid || '(not set — run "dbo login")');
19
+ log.label('Directory', process.cwd());
20
+
21
+ const cookiesPath = await getActiveCookiesPath();
22
+ const cookies = await loadCookies(cookiesPath);
23
+ if (cookies.length > 0) {
24
+ const sessionCookie = cookies.find(c => c.name.startsWith('dboioSession') || c.name.startsWith('ASP.NET'));
25
+ if (sessionCookie) {
26
+ const expires = sessionCookie.expiry > 0
27
+ ? new Date(sessionCookie.expiry * 1000).toISOString()
28
+ : 'session';
29
+ log.label('Session', `Active (expires: ${expires})`);
30
+ } else {
31
+ log.label('Session', `${cookies.length} cookie(s) stored`);
32
+ }
33
+ log.label('Cookies', cookiesPath);
34
+ } else {
35
+ log.label('Session', 'No active session. Run "dbo login".');
36
+ }
37
+ } catch (err) {
38
+ log.error(err.message);
39
+ process.exit(1);
40
+ }
41
+ });
@@ -0,0 +1,168 @@
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
+ }
@@ -0,0 +1,37 @@
1
+ import { Command } from 'commander';
2
+ import { basename } from 'path';
3
+ import { DboClient } from '../lib/client.js';
4
+ import { formatResponse, formatError } from '../lib/formatter.js';
5
+
6
+ export const uploadCommand = new Command('upload')
7
+ .description('Upload a file to DBO.io')
8
+ .argument('<filepath>', 'Local file to upload')
9
+ .requiredOption('--bin <id>', 'BinID')
10
+ .requiredOption('--app <id>', 'AppID')
11
+ .requiredOption('--ownership <type>', 'Ownership: app, user, or system')
12
+ .requiredOption('--path <dir>', 'Media path')
13
+ .option('--name <value>', 'Override filename')
14
+ .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
15
+ .option('--json', 'Output raw JSON')
16
+ .option('-v, --verbose', 'Show HTTP request details')
17
+ .option('--domain <host>', 'Override domain')
18
+ .action(async (filepath, options) => {
19
+ try {
20
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
21
+ const fileName = options.name || basename(filepath);
22
+ const fields = {
23
+ 'RowID:add1;column:media.BinID': options.bin,
24
+ 'RowID:add1;column:media.AppID': options.app,
25
+ 'RowID:add1;column:media.Ownership': options.ownership,
26
+ 'RowID:add1;column:media.Path': options.path,
27
+ '_confirm': options.confirm,
28
+ };
29
+ const files = [{ fieldName: 'file', filePath: filepath, fileName }];
30
+ const result = await client.postMultipart('/api/input/submit', fields, files);
31
+ formatResponse(result, { json: options.json });
32
+ if (!result.successful) process.exit(1);
33
+ } catch (err) {
34
+ formatError(err);
35
+ process.exit(1);
36
+ }
37
+ });