@dboio/cli 0.9.8 → 0.11.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.
Files changed (38) hide show
  1. package/README.md +172 -70
  2. package/bin/dbo.js +2 -0
  3. package/bin/postinstall.js +9 -1
  4. package/package.json +3 -3
  5. package/plugins/claude/dbo/commands/dbo.md +3 -3
  6. package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
  7. package/src/commands/add.js +50 -0
  8. package/src/commands/clone.js +720 -552
  9. package/src/commands/content.js +7 -3
  10. package/src/commands/deploy.js +22 -7
  11. package/src/commands/diff.js +41 -3
  12. package/src/commands/init.js +42 -79
  13. package/src/commands/input.js +5 -0
  14. package/src/commands/login.js +2 -2
  15. package/src/commands/mv.js +3 -0
  16. package/src/commands/output.js +8 -10
  17. package/src/commands/pull.js +268 -87
  18. package/src/commands/push.js +814 -94
  19. package/src/commands/rm.js +4 -1
  20. package/src/commands/status.js +12 -1
  21. package/src/commands/sync.js +71 -0
  22. package/src/lib/client.js +10 -0
  23. package/src/lib/config.js +80 -8
  24. package/src/lib/delta.js +178 -25
  25. package/src/lib/diff.js +150 -20
  26. package/src/lib/folder-icon.js +120 -0
  27. package/src/lib/ignore.js +2 -3
  28. package/src/lib/input-parser.js +37 -10
  29. package/src/lib/metadata-templates.js +21 -4
  30. package/src/lib/migrations.js +75 -0
  31. package/src/lib/save-to-disk.js +1 -1
  32. package/src/lib/scaffold.js +58 -3
  33. package/src/lib/structure.js +158 -21
  34. package/src/lib/toe-stepping.js +381 -0
  35. package/src/migrations/001-transaction-key-preset-scope.js +35 -0
  36. package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
  37. package/src/migrations/003-move-deploy-config.js +50 -0
  38. package/src/migrations/004-rename-output-files.js +101 -0
@@ -1,114 +1,295 @@
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, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, resolveEntityDirPath } 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, loadBaselineForComparison } from '../lib/diff.js';
11
+ import { DboClient } from '../lib/client.js';
12
+ import { runPendingMigrations } from '../lib/migrations.js';
9
13
 
10
- function collect(value, previous) {
11
- return previous.concat([value]);
14
+ async function fileExists(path) {
15
+ try { await access(path); return true; } catch { return false; }
12
16
  }
13
17
 
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
- }
18
+ /**
19
+ * Fetch the full app JSON from the server (same endpoint as clone).
20
+ * Distinguishes between authentication failures and genuine "app not found".
21
+ */
22
+ async function fetchAppJson(config, options) {
23
+ if (!config.AppShortName) {
24
+ throw new Error('No AppShortName in config. Run "dbo clone" first to set up the project.');
25
+ }
26
+
27
+ const appShortName = config.AppShortName;
28
+ const client = new DboClient({ domain: options.domain || config.domain, verbose: options.verbose });
29
+ const ora = (await import('ora')).default;
30
+ const spinner = ora(`Fetching app "${appShortName}" from server...`).start();
31
+
32
+ let result;
33
+ try {
34
+ result = await client.get(`/api/app/object/${appShortName}`);
35
+ } catch (err) {
36
+ spinner.fail(`Failed to fetch app "${appShortName}"`);
37
+ throw err;
38
+ }
39
+
40
+ // Check for authentication / session errors before parsing app data
41
+ const AUTH_PATTERNS = ['LoggedInUser_UID', 'LoggedInUserID', 'CurrentUserID', 'UserID', 'not authenticated', 'session expired', 'login required'];
42
+ const messages = result.messages || [];
43
+ const allMsgText = messages.filter(m => typeof m === 'string').join(' ');
44
+ const isAuthError = !result.ok && (result.status === 401 || result.status === 403)
45
+ || (!result.successful && AUTH_PATTERNS.some(p => allMsgText.includes(p)));
46
+
47
+ if (isAuthError) {
48
+ spinner.fail('Session expired or not authenticated');
49
+ log.warn('Your session appears to have expired.');
50
+ if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
51
+
52
+ if (process.stdin.isTTY) {
53
+ const inquirer = (await import('inquirer')).default;
54
+ const { action } = await inquirer.prompt([{
55
+ type: 'list',
56
+ name: 'action',
57
+ message: 'How would you like to proceed?',
58
+ choices: [
59
+ { name: 'Re-login now (recommended)', value: 'relogin' },
60
+ { name: 'Abort', value: 'abort' },
61
+ ],
62
+ }]);
63
+
64
+ if (action === 'relogin') {
65
+ const { performLogin } = await import('./login.js');
66
+ await performLogin(options.domain || config.domain);
67
+ log.info('Retrying app fetch...');
68
+ return fetchAppJson(config, options);
42
69
  }
70
+ } else {
71
+ log.dim(' Run "dbo login" to authenticate, then retry.');
72
+ }
73
+ throw new Error('Authentication required. Run "dbo login" first.');
74
+ }
75
+
76
+ // Check for non-auth server errors
77
+ if (!result.ok && result.status >= 500) {
78
+ spinner.fail(`Server error (HTTP ${result.status})`);
79
+ if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
80
+ throw new Error(`Server error (HTTP ${result.status}) fetching app "${appShortName}"`);
81
+ }
82
+
83
+ if (!result.successful && allMsgText) {
84
+ spinner.fail(`Server returned an error`);
85
+ log.warn(` ${allMsgText.substring(0, 300)}`);
86
+ throw new Error(`Server error fetching app "${appShortName}": ${allMsgText.substring(0, 200)}`);
87
+ }
88
+
89
+ const data = result.payload || result.data;
90
+ let appRecord;
91
+ if (Array.isArray(data)) {
92
+ appRecord = data.length > 0 ? data[0] : null;
93
+ } else if (data?.Rows?.length > 0) {
94
+ appRecord = data.Rows[0];
95
+ } else if (data?.rows?.length > 0) {
96
+ appRecord = data.rows[0];
97
+ } else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
98
+ appRecord = data;
99
+ } else {
100
+ appRecord = null;
101
+ }
102
+
103
+ if (!appRecord || !appRecord.children) {
104
+ spinner.fail(`No app found or invalid response for "${appShortName}"`);
105
+ throw new Error(`No app found with ShortName "${appShortName}"`);
106
+ }
107
+
108
+ spinner.succeed('Fetched app from server');
109
+ return appRecord;
110
+ }
111
+
112
+ /**
113
+ * Dry-run scan: compare server records against local metadata files.
114
+ * Reports counts of new, changed, and up-to-date records without writing anything.
115
+ */
116
+ async function dryRunScan(appJson, options) {
117
+ const config = await loadConfig();
118
+ const serverTz = config.ServerTimezone || 'America/Los_Angeles';
119
+ const configWithTz = { ...config, ServerTimezone: serverTz };
120
+
121
+ // Pre-load baseline for fast _LastUpdated string comparison in isServerNewer
122
+ await loadBaselineForComparison();
123
+
124
+ const structure = await loadStructureFile();
125
+ const saved = await loadClonePlacement();
126
+ const contentPlacement = saved.contentPlacement || 'bin';
127
+
128
+ const entityFilter = resolveEntityFilter(options.entity);
129
+
130
+ const counts = { new: 0, changed: 0, upToDate: 0 };
131
+ const details = { new: [], changed: [] };
132
+
133
+ // Scan content records
134
+ if (!entityFilter || entityFilter.has('content')) {
135
+ for (const record of (appJson.children.content || [])) {
136
+ const { metaPath } = resolveRecordPaths('content', record, structure, contentPlacement);
137
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, 'content');
138
+ }
139
+ }
43
140
 
44
- // Pass sort values through directly — API expects _sort=Column:DIR
45
- if (options.sort.length > 0) {
46
- params['_sort'] = options.sort;
141
+ // Scan media records
142
+ if (!entityFilter || entityFilter.has('media')) {
143
+ for (const record of (appJson.children.media || [])) {
144
+ const { metaPath } = resolveMediaPaths(record, structure);
145
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, 'media');
146
+ }
147
+ }
148
+
149
+ // Scan entity-dir records (extension, site, data_source, etc.)
150
+ for (const [entityName, entries] of Object.entries(appJson.children)) {
151
+ if (['bin', 'content', 'media', 'output', 'output_value', 'output_value_filter', 'output_value_entity_column_rel'].includes(entityName)) continue;
152
+ if (!Array.isArray(entries) || entries.length === 0) continue;
153
+ if (entityFilter && !entityFilter.has(entityName)) continue;
154
+
155
+ if (entityName === 'extension') {
156
+ // Extensions use descriptor sub-directories
157
+ for (const record of entries) {
158
+ const dirName = resolveExtensionDir(record);
159
+ const { metaPath } = resolveEntityDirPaths(entityName, record, dirName);
160
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, entityName);
161
+ }
162
+ } else {
163
+ for (const record of entries) {
164
+ const { metaPath } = resolveEntityDirPaths(entityName, record, resolveEntityDirPath(entityName));
165
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, entityName);
47
166
  }
167
+ }
168
+ }
48
169
 
49
- let path;
50
- if (uid) {
51
- path = `/api/output/entity/${entity}/${uid}`;
52
- } else {
53
- path = `/api/output/entity/${entity}`;
170
+ // Scan output hierarchy (compound single-file format)
171
+ if (!entityFilter || entityFilter.has('output')) {
172
+ const outputEntries = appJson.children.output || [];
173
+ for (const record of outputEntries) {
174
+ // Resolve bin directory for this output (same logic as clone)
175
+ let binDir = BINS_DIR;
176
+ if (record.BinID && structure[record.BinID]) {
177
+ binDir = resolveBinPath(record.BinID, structure);
54
178
  }
179
+ const rootBasename = buildOutputFilename('output', record, 'Name');
180
+ const metaPath = join(binDir, `${rootBasename}.json`);
181
+ await scanRecord(record, metaPath, serverTz, configWithTz, counts, details, 'output');
182
+ }
183
+ }
55
184
 
56
- const result = await client.get(path, params);
185
+ // Print summary
186
+ log.plain('');
187
+ log.info('Dry-run scan results:');
188
+ log.plain('');
57
189
 
58
- log.success(`Pulled from ${chalk.underline(result.url || '')}`);
190
+ const total = counts.new + counts.changed + counts.upToDate;
59
191
 
60
- const data = result.payload || result.data;
192
+ if (counts.new > 0) {
193
+ log.success(` ${chalk.green(counts.new)} new record(s)`);
194
+ for (const d of details.new.slice(0, 10)) {
195
+ log.dim(` + [${d.entity}] ${d.name}`);
196
+ }
197
+ if (details.new.length > 10) log.dim(` ... and ${details.new.length - 10} more`);
198
+ }
61
199
 
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
- }
200
+ if (counts.changed > 0) {
201
+ log.warn(` ${chalk.yellow(counts.changed)} changed record(s)`);
202
+ for (const d of details.changed.slice(0, 10)) {
203
+ log.dim(` ~ [${d.entity}] ${d.name}`);
204
+ }
205
+ if (details.changed.length > 10) log.dim(` ... and ${details.changed.length - 10} more`);
206
+ }
68
207
 
69
- const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || [data]);
208
+ if (counts.upToDate > 0) {
209
+ log.dim(` ${counts.upToDate} up-to-date record(s)`);
210
+ }
70
211
 
71
- if (rows.length === 0) {
72
- log.warn('No records found.');
73
- return;
74
- }
212
+ log.plain('');
213
+ if (counts.new === 0 && counts.changed === 0) {
214
+ log.success(`All records up to date (${total} checked)`);
215
+ } else {
216
+ log.info(`${total} record(s) checked. Run "dbo pull" (without --dry-run) to apply changes.`);
217
+ }
218
+ }
75
219
 
76
- log.info(`${rows.length} record(s) to save`);
220
+ /**
221
+ * Check a single record against its local metadata file.
222
+ */
223
+ async function scanRecord(record, metaPath, _serverTz, configWithTz, counts, details, entityName) {
224
+ const name = record.Name || record.UID || 'unknown';
225
+ const exists = await fileExists(metaPath);
77
226
 
78
- const columns = Object.keys(rows[0]);
227
+ if (!exists) {
228
+ counts.new++;
229
+ details.new.push({ entity: entityName, name });
230
+ return;
231
+ }
79
232
 
80
- if (columns.length <= 1) {
81
- log.error(`Response has no usable columns. Check the entity name and UID.`);
82
- process.exit(1);
83
- }
233
+ const localSyncTime = await getLocalSyncTime(metaPath);
234
+ if (record._LastUpdated && isServerNewer(localSyncTime, record._LastUpdated, configWithTz, entityName, record.UID)) {
235
+ counts.changed++;
236
+ details.changed.push({ entity: entityName, name });
237
+ } else {
238
+ counts.upToDate++;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Resolve the directory for an extension record (descriptor-based sub-dirs).
244
+ */
245
+ function resolveExtensionDir(record) {
246
+ const descriptor = record.Descriptor;
247
+ if (!descriptor) return EXTENSION_UNSUPPORTED_DIR;
248
+ if (descriptor === 'descriptor_definition') return EXTENSION_DESCRIPTORS_DIR;
249
+ // Use the descriptor value as sub-directory name
250
+ return `${EXTENSION_DESCRIPTORS_DIR}/${descriptor}`;
251
+ }
84
252
 
85
- // Content entity has well-known defaults — skip prompts
86
- const isContent = entity === 'content';
253
+ export const pullCommand = new Command('pull')
254
+ .description('Pull updates from server into existing project')
255
+ .option('-e, --entity <type>', 'Only pull a specific entity type')
256
+ .option('-y, --yes', 'Auto-accept all incoming changes')
257
+ .option('-n, --dry-run', 'Show what would change without writing files')
258
+ .option('-v, --verbose', 'Show HTTP request details')
259
+ .option('--domain <host>', 'Override domain')
260
+ .option('--force', 'Force re-processing, skip change detection')
261
+ .option('--descriptor-types <bool>', 'Sort extensions into descriptor sub-directories (default: true)', 'true')
262
+ .option('--documentation-only', 'When used with -e extension, pull only documentation extensions')
263
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
264
+ .action(async (options) => {
265
+ try {
266
+ await runPendingMigrations(options);
87
267
  const config = await loadConfig();
88
268
 
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
- });
269
+ if (!config.AppShortName) {
270
+ log.error('No AppShortName found in .dbo/config.json.');
271
+ log.dim(' Run "dbo clone" first to set up the project.');
272
+ process.exit(1);
273
+ }
274
+
275
+ // Dry-run mode: scan and report without writing
276
+ if (options.dryRun) {
277
+ const appJson = await fetchAppJson(config, options);
278
+ await dryRunScan(appJson, options);
279
+ return;
111
280
  }
281
+
282
+ // Full pull: delegate to performClone with pullMode
283
+ await performClone(null, {
284
+ pullMode: true,
285
+ yes: options.yes,
286
+ entity: options.entity,
287
+ force: options.force,
288
+ domain: options.domain,
289
+ verbose: options.verbose,
290
+ descriptorTypes: options.descriptorTypes,
291
+ documentationOnly: options.documentationOnly,
292
+ });
112
293
  } catch (err) {
113
294
  formatError(err);
114
295
  process.exit(1);