@dboio/cli 0.9.8 → 0.10.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.
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { access, readdir } from 'fs/promises';
2
+ import { readdir, mkdir } from 'fs/promises';
3
3
  import { join } from 'path';
4
- import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
4
+ import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput, loadConfig } from '../lib/config.js';
5
5
  import { installOrUpdateClaudeCommands } from './install.js';
6
6
  import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
7
7
  import { createDboignore, loadIgnore } from '../lib/ignore.js';
@@ -40,7 +40,13 @@ export const initCommand = new Command('init')
40
40
 
41
41
  if (await isInitialized() && !options.force) {
42
42
  if (options.scaffold) {
43
- const result = await scaffoldProjectDirs();
43
+ // Try to pass app short name for media sub-directory creation
44
+ let shortName;
45
+ try {
46
+ const cfg = await loadConfig();
47
+ shortName = cfg.AppShortName;
48
+ } catch { /* no config yet */ }
49
+ const result = await scaffoldProjectDirs(process.cwd(), { appShortName: shortName });
44
50
  logScaffoldResult(result);
45
51
  return;
46
52
  }
@@ -99,13 +105,24 @@ export const initCommand = new Command('init')
99
105
  }
100
106
 
101
107
  // Ensure sensitive files are gitignored
102
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', 'trash/']);
108
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', 'trash/', 'Icon\\r']);
103
109
 
104
110
  const createdIgnore = await createDboignore();
105
111
  if (createdIgnore) log.dim(' Created .dboignore');
106
112
 
113
+ // Ensure .claude/ directory structure exists
114
+ const claudeSubDirs = ['1_prompts', '2_specs', '3_plans', 'commands'];
115
+ for (const sub of claudeSubDirs) {
116
+ await mkdir(join(process.cwd(), '.claude', sub), { recursive: true });
117
+ }
118
+ log.dim(' Created .claude/ directory structure');
119
+
107
120
  log.success(`Initialized .dbo/ for ${domain}`);
108
- log.dim(' Run "dbo login" to authenticate.');
121
+
122
+ // Authenticate early so the session is ready for subsequent operations
123
+ if (!options.nonInteractive && username) {
124
+ await performLogin(domain, username);
125
+ }
109
126
 
110
127
  // Prompt for TransactionKeyPreset
111
128
  if (!options.nonInteractive) {
@@ -167,7 +184,7 @@ export const initCommand = new Command('init')
167
184
  logScaffoldResult(result);
168
185
  }
169
186
 
170
- // Clone if requested — requires authentication first
187
+ // Clone if requested
171
188
  if (options.clone || options.app) {
172
189
  let appShortName = options.app;
173
190
  if (!appShortName) {
@@ -180,38 +197,19 @@ export const initCommand = new Command('init')
180
197
  appShortName = appName;
181
198
  }
182
199
 
183
- // Authenticate before fetching app data from the server
184
- if (!options.nonInteractive) {
185
- log.info('Login required to fetch app data from the server.');
186
- await performLogin(domain, username);
187
- }
188
-
189
200
  const { performClone } = await import('./clone.js');
190
201
  await performClone(null, { app: appShortName, domain, mediaPlacement: options.mediaPlacement });
191
202
  }
192
203
 
193
- // Offer Claude Code integration (skip in non-interactive mode)
204
+ // Offer Claude Code plugin installation (skip in non-interactive mode)
194
205
  if (!options.nonInteractive) {
195
- const claudeDir = join(process.cwd(), '.claude');
196
206
  const inquirer = (await import('inquirer')).default;
197
- let hasClaudeDir = false;
198
- try { await access(claudeDir); hasClaudeDir = true; } catch {}
199
-
200
- if (hasClaudeDir) {
201
- const { installCmds } = await inquirer.prompt([{
202
- type: 'confirm', name: 'installCmds',
203
- message: 'Install DBO commands for Claude Code? (adds /dbo slash command)',
204
- default: true,
205
- }]);
206
- if (installCmds) await installOrUpdateClaudeCommands(options);
207
- } else {
208
- const { setupClaude } = await inquirer.prompt([{
209
- type: 'confirm', name: 'setupClaude',
210
- message: 'Set up Claude Code integration? (creates .claude/commands/)',
211
- default: false,
212
- }]);
213
- if (setupClaude) await installOrUpdateClaudeCommands(options);
214
- }
207
+ const { installCmds } = await inquirer.prompt([{
208
+ type: 'confirm', name: 'installCmds',
209
+ message: 'Install DBO commands for Claude Code? (adds /dbo slash command)',
210
+ default: true,
211
+ }]);
212
+ if (installCmds) await installOrUpdateClaudeCommands(options);
215
213
  }
216
214
  } catch (err) {
217
215
  log.error(err.message);
@@ -1,114 +1,291 @@
1
1
  import { Command } from 'commander';
2
+ import { access } from 'fs/promises';
3
+ import { join } from 'path';
2
4
  import chalk from 'chalk';
3
- import { DboClient } from '../lib/client.js';
4
- import { loadConfig } from '../lib/config.js';
5
- import { formatError } from '../lib/formatter.js';
6
- import { saveToDisk } from '../lib/save-to-disk.js';
5
+ import { performClone, resolveRecordPaths, resolveMediaPaths, resolveEntityDirPaths, resolveEntityFilter, buildOutputFilename } from './clone.js';
6
+ import { loadConfig, loadClonePlacement } from '../lib/config.js';
7
+ import { loadStructureFile, resolveBinPath, BINS_DIR, ENTITY_DIR_NAMES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR } from '../lib/structure.js';
7
8
  import { log } from '../lib/logger.js';
8
- import { renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
9
+ import { formatError } from '../lib/formatter.js';
10
+ import { getLocalSyncTime, isServerNewer } from '../lib/diff.js';
11
+ import { DboClient } from '../lib/client.js';
9
12
 
10
- function collect(value, previous) {
11
- return previous.concat([value]);
13
+ async function fileExists(path) {
14
+ try { await access(path); return true; } catch { return false; }
12
15
  }
13
16
 
14
- export const pullCommand = new Command('pull')
15
- .description('Pull records from DBO.io to local files (default entity: content)')
16
- .argument('[uid]', 'Record UID (optional)')
17
- .option('-e, --entity <uid>', 'Entity to pull from (default: content)', 'content')
18
- .option('--filter <expr>', 'Filter expression (repeatable)', collect, [])
19
- .option('--maxrows <n>', 'Maximum rows to pull')
20
- .option('--sort <expr>', 'Sort expression (repeatable)', collect, [])
21
- .option('-v, --verbose', 'Show HTTP request details')
22
- .option('--domain <host>', 'Override domain')
23
- .action(async (uid, options) => {
24
- try {
25
- const client = new DboClient({ domain: options.domain, verbose: options.verbose });
26
- const entity = options.entity;
27
- const params = { '_format': 'json_raw' };
28
- if (options.maxrows) params['_maxrows'] = options.maxrows;
29
-
30
- for (const f of options.filter) {
31
- const atIdx = f.indexOf('=');
32
- if (atIdx !== -1) {
33
- const key = f.substring(0, atIdx);
34
- const value = f.substring(atIdx + 1);
35
- if (key.includes(':')) {
36
- const [col, mod] = key.split(':');
37
- params[`_filter@${col}:${mod}`] = value;
38
- } else {
39
- params[`_filter@${key}`] = value;
40
- }
41
- }
17
+ /**
18
+ * Fetch the full app JSON from the server (same endpoint as clone).
19
+ * Distinguishes between authentication failures and genuine "app not found".
20
+ */
21
+ async function fetchAppJson(config, options) {
22
+ if (!config.AppShortName) {
23
+ throw new Error('No AppShortName in config. Run "dbo clone" first to set up the project.');
24
+ }
25
+
26
+ const appShortName = config.AppShortName;
27
+ const client = new DboClient({ domain: options.domain || config.domain, verbose: options.verbose });
28
+ const ora = (await import('ora')).default;
29
+ const spinner = ora(`Fetching app "${appShortName}" from server...`).start();
30
+
31
+ let result;
32
+ try {
33
+ result = await client.get(`/api/app/object/${appShortName}`);
34
+ } catch (err) {
35
+ spinner.fail(`Failed to fetch app "${appShortName}"`);
36
+ throw err;
37
+ }
38
+
39
+ // Check for authentication / session errors before parsing app data
40
+ const AUTH_PATTERNS = ['LoggedInUser_UID', 'LoggedInUserID', 'CurrentUserID', 'UserID', 'not authenticated', 'session expired', 'login required'];
41
+ const messages = result.messages || [];
42
+ const allMsgText = messages.filter(m => typeof m === 'string').join(' ');
43
+ const isAuthError = !result.ok && (result.status === 401 || result.status === 403)
44
+ || (!result.successful && AUTH_PATTERNS.some(p => allMsgText.includes(p)));
45
+
46
+ if (isAuthError) {
47
+ spinner.fail('Session expired or not authenticated');
48
+ log.warn('Your session appears to have expired.');
49
+ if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
50
+
51
+ if (process.stdin.isTTY) {
52
+ const inquirer = (await import('inquirer')).default;
53
+ const { action } = await inquirer.prompt([{
54
+ type: 'list',
55
+ name: 'action',
56
+ message: 'How would you like to proceed?',
57
+ choices: [
58
+ { name: 'Re-login now (recommended)', value: 'relogin' },
59
+ { name: 'Abort', value: 'abort' },
60
+ ],
61
+ }]);
62
+
63
+ if (action === 'relogin') {
64
+ const { performLogin } = await import('./login.js');
65
+ await performLogin(options.domain || config.domain);
66
+ log.info('Retrying app fetch...');
67
+ return fetchAppJson(config, options);
42
68
  }
69
+ } else {
70
+ log.dim(' Run "dbo login" to authenticate, then retry.');
71
+ }
72
+ throw new Error('Authentication required. Run "dbo login" first.');
73
+ }
74
+
75
+ // Check for non-auth server errors
76
+ if (!result.ok && result.status >= 500) {
77
+ spinner.fail(`Server error (HTTP ${result.status})`);
78
+ if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
79
+ throw new Error(`Server error (HTTP ${result.status}) fetching app "${appShortName}"`);
80
+ }
81
+
82
+ if (!result.successful && allMsgText) {
83
+ spinner.fail(`Server returned an error`);
84
+ log.warn(` ${allMsgText.substring(0, 300)}`);
85
+ throw new Error(`Server error fetching app "${appShortName}": ${allMsgText.substring(0, 200)}`);
86
+ }
87
+
88
+ const data = result.payload || result.data;
89
+ let appRecord;
90
+ if (Array.isArray(data)) {
91
+ appRecord = data.length > 0 ? data[0] : null;
92
+ } else if (data?.Rows?.length > 0) {
93
+ appRecord = data.Rows[0];
94
+ } else if (data?.rows?.length > 0) {
95
+ appRecord = data.rows[0];
96
+ } else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
97
+ appRecord = data;
98
+ } else {
99
+ appRecord = null;
100
+ }
101
+
102
+ if (!appRecord || !appRecord.children) {
103
+ spinner.fail(`No app found or invalid response for "${appShortName}"`);
104
+ throw new Error(`No app found with ShortName "${appShortName}"`);
105
+ }
106
+
107
+ spinner.succeed('Fetched app from server');
108
+ return appRecord;
109
+ }
110
+
111
+ /**
112
+ * Dry-run scan: compare server records against local metadata files.
113
+ * Reports counts of new, changed, and up-to-date records without writing anything.
114
+ */
115
+ async function dryRunScan(appJson, options) {
116
+ const config = await loadConfig();
117
+ const serverTz = config.ServerTimezone || 'America/Los_Angeles';
118
+ const configWithTz = { ...config, ServerTimezone: serverTz };
119
+
120
+ const structure = await loadStructureFile();
121
+ const saved = await loadClonePlacement();
122
+ const contentPlacement = saved.contentPlacement || 'bin';
123
+ const mediaPlacement = saved.mediaPlacement || 'bin';
124
+
125
+ const entityFilter = resolveEntityFilter(options.entity);
126
+
127
+ const counts = { new: 0, changed: 0, upToDate: 0 };
128
+ const details = { new: [], changed: [] };
129
+
130
+ // Scan content records
131
+ if (!entityFilter || entityFilter.has('content')) {
132
+ for (const record of (appJson.children.content || [])) {
133
+ const { metaPath } = resolveRecordPaths('content', record, structure, contentPlacement);
134
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, 'content');
135
+ }
136
+ }
43
137
 
44
- // Pass sort values through directly — API expects _sort=Column:DIR
45
- if (options.sort.length > 0) {
46
- params['_sort'] = options.sort;
138
+ // Scan media records
139
+ if (!entityFilter || entityFilter.has('media')) {
140
+ for (const record of (appJson.children.media || [])) {
141
+ const { metaPath } = resolveMediaPaths(record, structure, mediaPlacement);
142
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, 'media');
143
+ }
144
+ }
145
+
146
+ // Scan entity-dir records (extension, site, data_source, etc.)
147
+ for (const [entityName, entries] of Object.entries(appJson.children)) {
148
+ if (['bin', 'content', 'media', 'output', 'output_value', 'output_value_filter', 'output_value_entity_column_rel'].includes(entityName)) continue;
149
+ if (!Array.isArray(entries) || entries.length === 0) continue;
150
+ if (entityFilter && !entityFilter.has(entityName)) continue;
151
+
152
+ if (entityName === 'extension') {
153
+ // Extensions use descriptor sub-directories
154
+ for (const record of entries) {
155
+ const dirName = resolveExtensionDir(record);
156
+ const { metaPath } = resolveEntityDirPaths(entityName, record, dirName);
157
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, entityName);
158
+ }
159
+ } else if (ENTITY_DIR_NAMES.has(entityName)) {
160
+ for (const record of entries) {
161
+ const { metaPath } = resolveEntityDirPaths(entityName, record, entityName);
162
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, entityName);
47
163
  }
164
+ }
165
+ // Generic entities in bins — skip for simplicity in dry-run (they use content placement)
166
+ }
48
167
 
49
- let path;
50
- if (uid) {
51
- path = `/api/output/entity/${entity}/${uid}`;
52
- } else {
53
- path = `/api/output/entity/${entity}`;
168
+ // Scan output hierarchy (compound single-file format)
169
+ if (!entityFilter || entityFilter.has('output')) {
170
+ const outputEntries = appJson.children.output || [];
171
+ for (const record of outputEntries) {
172
+ // Resolve bin directory for this output (same logic as clone)
173
+ let binDir = BINS_DIR;
174
+ if (record.BinID && structure[record.BinID]) {
175
+ binDir = resolveBinPath(record.BinID, structure);
54
176
  }
177
+ const rootBasename = buildOutputFilename('output', record, 'Name');
178
+ const metaPath = join(binDir, `${rootBasename}.json`);
179
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, 'output');
180
+ }
181
+ }
55
182
 
56
- const result = await client.get(path, params);
183
+ // Print summary
184
+ log.plain('');
185
+ log.info('Dry-run scan results:');
186
+ log.plain('');
57
187
 
58
- log.success(`Pulled from ${chalk.underline(result.url || '')}`);
188
+ const total = counts.new + counts.changed + counts.upToDate;
59
189
 
60
- const data = result.payload || result.data;
190
+ if (counts.new > 0) {
191
+ log.success(` ${chalk.green(counts.new)} new record(s)`);
192
+ for (const d of details.new.slice(0, 10)) {
193
+ log.dim(` + [${d.entity}] ${d.name}`);
194
+ }
195
+ if (details.new.length > 10) log.dim(` ... and ${details.new.length - 10} more`);
196
+ }
61
197
 
62
- // Validate we got actual JSON data, not raw HTML
63
- if (data && data.raw && typeof data.raw === 'string') {
64
- log.error(`Received non-JSON response. Check that entity "${entity}" exists.`);
65
- log.dim(` Hint: use --entity (double dash) not -entity`);
66
- process.exit(1);
67
- }
198
+ if (counts.changed > 0) {
199
+ log.warn(` ${chalk.yellow(counts.changed)} changed record(s)`);
200
+ for (const d of details.changed.slice(0, 10)) {
201
+ log.dim(` ~ [${d.entity}] ${d.name}`);
202
+ }
203
+ if (details.changed.length > 10) log.dim(` ... and ${details.changed.length - 10} more`);
204
+ }
68
205
 
69
- const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || [data]);
206
+ if (counts.upToDate > 0) {
207
+ log.dim(` ${counts.upToDate} up-to-date record(s)`);
208
+ }
70
209
 
71
- if (rows.length === 0) {
72
- log.warn('No records found.');
73
- return;
74
- }
210
+ log.plain('');
211
+ if (counts.new === 0 && counts.changed === 0) {
212
+ log.success(`All records up to date (${total} checked)`);
213
+ } else {
214
+ log.info(`${total} record(s) checked. Run "dbo pull" (without --dry-run) to apply changes.`);
215
+ }
216
+ }
75
217
 
76
- log.info(`${rows.length} record(s) to save`);
218
+ /**
219
+ * Check a single record against its local metadata file.
220
+ */
221
+ async function scanRecord(record, metaPath, _serverTz, configWithTz, counts, details, entityName) {
222
+ const name = record.Name || record.UID || 'unknown';
223
+ const exists = await fileExists(metaPath);
77
224
 
78
- const columns = Object.keys(rows[0]);
225
+ if (!exists) {
226
+ counts.new++;
227
+ details.new.push({ entity: entityName, name });
228
+ return;
229
+ }
79
230
 
80
- if (columns.length <= 1) {
81
- log.error(`Response has no usable columns. Check the entity name and UID.`);
82
- process.exit(1);
83
- }
231
+ const localSyncTime = await getLocalSyncTime(metaPath);
232
+ if (record._LastUpdated && isServerNewer(localSyncTime, record._LastUpdated, configWithTz)) {
233
+ counts.changed++;
234
+ details.changed.push({ entity: entityName, name });
235
+ } else {
236
+ counts.upToDate++;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Resolve the directory for an extension record (descriptor-based sub-dirs).
242
+ */
243
+ function resolveExtensionDir(record) {
244
+ const descriptor = record.Descriptor;
245
+ if (!descriptor) return EXTENSION_UNSUPPORTED_DIR;
246
+ if (descriptor === 'descriptor_definition') return EXTENSION_DESCRIPTORS_DIR;
247
+ // Use the descriptor value as sub-directory name
248
+ return `${EXTENSION_DESCRIPTORS_DIR}/${descriptor}`;
249
+ }
84
250
 
85
- // Content entity has well-known defaults — skip prompts
86
- const isContent = entity === 'content';
251
+ export const pullCommand = new Command('pull')
252
+ .description('Pull updates from server into existing project')
253
+ .option('-e, --entity <type>', 'Only pull a specific entity type')
254
+ .option('-y, --yes', 'Auto-accept all incoming changes')
255
+ .option('-n, --dry-run', 'Show what would change without writing files')
256
+ .option('-v, --verbose', 'Show HTTP request details')
257
+ .option('--domain <host>', 'Override domain')
258
+ .option('--force', 'Force re-processing, skip change detection')
259
+ .option('--descriptor-types <bool>', 'Sort extensions into descriptor sub-directories (default: true)', 'true')
260
+ .option('--documentation-only', 'When used with -e extension, pull only documentation extensions')
261
+ .action(async (options) => {
262
+ try {
87
263
  const config = await loadConfig();
88
264
 
89
- if (isContent) {
90
- await saveToDisk(rows, columns, {
91
- entity,
92
- saveFilename: columns.includes('Name') ? 'Name' : 'UID',
93
- savePath: columns.includes('Path') ? 'Path' : null,
94
- saveContent: columns.includes('Content') ? 'Content' : null,
95
- saveExtension: columns.includes('Extension') ? 'Extension' : null,
96
- nonInteractive: false, // still prompt for overwrites
97
- contentFileMap: columns.includes('Content') ? {
98
- Content: { suffix: '', extensionSource: columns.includes('Extension') ? 'Extension' : null, extension: 'txt' },
99
- } : null,
100
- changeDetection: true,
101
- config,
102
- });
103
- } else {
104
- // Other entities: interactive prompts for column selection
105
- await saveToDisk(rows, columns, {
106
- entity,
107
- nonInteractive: false,
108
- changeDetection: true,
109
- config,
110
- });
265
+ if (!config.AppShortName) {
266
+ log.error('No AppShortName found in .dbo/config.json.');
267
+ log.dim(' Run "dbo clone" first to set up the project.');
268
+ process.exit(1);
269
+ }
270
+
271
+ // Dry-run mode: scan and report without writing
272
+ if (options.dryRun) {
273
+ const appJson = await fetchAppJson(config, options);
274
+ await dryRunScan(appJson, options);
275
+ return;
111
276
  }
277
+
278
+ // Full pull: delegate to performClone with pullMode
279
+ await performClone(null, {
280
+ pullMode: true,
281
+ yes: options.yes,
282
+ entity: options.entity,
283
+ force: options.force,
284
+ domain: options.domain,
285
+ verbose: options.verbose,
286
+ descriptorTypes: options.descriptorTypes,
287
+ documentationOnly: options.documentationOnly,
288
+ });
112
289
  } catch (err) {
113
290
  formatError(err);
114
291
  process.exit(1);