@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.
- package/README.md +172 -70
- package/bin/dbo.js +2 -0
- package/bin/postinstall.js +9 -1
- package/package.json +3 -3
- package/plugins/claude/dbo/commands/dbo.md +3 -3
- package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
- package/src/commands/add.js +50 -0
- package/src/commands/clone.js +720 -552
- package/src/commands/content.js +7 -3
- package/src/commands/deploy.js +22 -7
- package/src/commands/diff.js +41 -3
- package/src/commands/init.js +42 -79
- package/src/commands/input.js +5 -0
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +3 -0
- package/src/commands/output.js +8 -10
- package/src/commands/pull.js +268 -87
- package/src/commands/push.js +814 -94
- package/src/commands/rm.js +4 -1
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +71 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +80 -8
- package/src/lib/delta.js +178 -25
- package/src/lib/diff.js +150 -20
- package/src/lib/folder-icon.js +120 -0
- package/src/lib/ignore.js +2 -3
- package/src/lib/input-parser.js +37 -10
- package/src/lib/metadata-templates.js +21 -4
- package/src/lib/migrations.js +75 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/scaffold.js +58 -3
- package/src/lib/structure.js +158 -21
- package/src/lib/toe-stepping.js +381 -0
- package/src/migrations/001-transaction-key-preset-scope.js +35 -0
- package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
- package/src/migrations/003-move-deploy-config.js +50 -0
- package/src/migrations/004-rename-output-files.js +101 -0
package/src/commands/pull.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import { loadConfig } from '../lib/config.js';
|
|
5
|
-
import {
|
|
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 {
|
|
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
|
|
11
|
-
|
|
14
|
+
async function fileExists(path) {
|
|
15
|
+
try { await access(path); return true; } catch { return false; }
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
185
|
+
// Print summary
|
|
186
|
+
log.plain('');
|
|
187
|
+
log.info('Dry-run scan results:');
|
|
188
|
+
log.plain('');
|
|
57
189
|
|
|
58
|
-
|
|
190
|
+
const total = counts.new + counts.changed + counts.upToDate;
|
|
59
191
|
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
208
|
+
if (counts.upToDate > 0) {
|
|
209
|
+
log.dim(` ${counts.upToDate} up-to-date record(s)`);
|
|
210
|
+
}
|
|
70
211
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
if (!exists) {
|
|
228
|
+
counts.new++;
|
|
229
|
+
details.new.push({ entity: entityName, name });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
79
232
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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);
|