@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.
- package/README.md +1161 -0
- package/bin/dbo.js +51 -0
- package/package.json +22 -0
- package/src/commands/add.js +374 -0
- package/src/commands/cache.js +49 -0
- package/src/commands/clone.js +742 -0
- package/src/commands/content.js +143 -0
- package/src/commands/deploy.js +89 -0
- package/src/commands/init.js +105 -0
- package/src/commands/input.js +111 -0
- package/src/commands/install.js +186 -0
- package/src/commands/instance.js +44 -0
- package/src/commands/login.js +97 -0
- package/src/commands/logout.js +22 -0
- package/src/commands/media.js +46 -0
- package/src/commands/message.js +28 -0
- package/src/commands/output.js +129 -0
- package/src/commands/pull.js +109 -0
- package/src/commands/push.js +309 -0
- package/src/commands/status.js +41 -0
- package/src/commands/update.js +168 -0
- package/src/commands/upload.js +37 -0
- package/src/lib/client.js +161 -0
- package/src/lib/columns.js +30 -0
- package/src/lib/config.js +269 -0
- package/src/lib/cookie-jar.js +104 -0
- package/src/lib/formatter.js +310 -0
- package/src/lib/input-parser.js +212 -0
- package/src/lib/logger.js +12 -0
- package/src/lib/save-to-disk.js +383 -0
- package/src/lib/structure.js +129 -0
- package/src/lib/timestamps.js +67 -0
- package/src/plugins/claudecommands/dbo.md +248 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
|
3
|
+
import { join, basename, extname } from 'path';
|
|
4
|
+
import { DboClient } from '../lib/client.js';
|
|
5
|
+
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore } from '../lib/config.js';
|
|
6
|
+
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, BINS_DIR, DEFAULT_PROJECT_DIRS } from '../lib/structure.js';
|
|
7
|
+
import { log } from '../lib/logger.js';
|
|
8
|
+
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a column value that may be base64-encoded.
|
|
12
|
+
* Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
|
|
13
|
+
*/
|
|
14
|
+
function resolveContentValue(value) {
|
|
15
|
+
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
+
&& value.encoding === 'base64' && typeof value.value === 'string') {
|
|
17
|
+
return Buffer.from(value.value, 'base64').toString('utf8');
|
|
18
|
+
}
|
|
19
|
+
return value !== null && value !== undefined ? String(value) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sanitizeFilename(name) {
|
|
23
|
+
return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-').substring(0, 200);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function fileExists(path) {
|
|
27
|
+
try { await access(path); return true; } catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const cloneCommand = new Command('clone')
|
|
31
|
+
.description('Clone an app from DBO.io to a local project structure')
|
|
32
|
+
.argument('[source]', 'Local JSON file path (optional)')
|
|
33
|
+
.option('--app <shortName>', 'App short name to fetch from server')
|
|
34
|
+
.option('--domain <host>', 'Override domain')
|
|
35
|
+
.option('-y, --yes', 'Auto-accept all prompts')
|
|
36
|
+
.option('-v, --verbose', 'Show HTTP request details')
|
|
37
|
+
.action(async (source, options) => {
|
|
38
|
+
try {
|
|
39
|
+
await performClone(source, options);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
log.error(err.message);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Main clone workflow. Exported for use by init --clone.
|
|
48
|
+
*/
|
|
49
|
+
export async function performClone(source, options = {}) {
|
|
50
|
+
const config = await loadConfig();
|
|
51
|
+
let appJson;
|
|
52
|
+
|
|
53
|
+
// Step 1: Load the app JSON
|
|
54
|
+
if (source) {
|
|
55
|
+
// Local file
|
|
56
|
+
log.info(`Loading app JSON from ${source}...`);
|
|
57
|
+
const raw = await readFile(source, 'utf8');
|
|
58
|
+
appJson = JSON.parse(raw);
|
|
59
|
+
} else if (options.app) {
|
|
60
|
+
// Fetch from server by AppShortName
|
|
61
|
+
appJson = await fetchAppFromServer(options.app, options, config);
|
|
62
|
+
} else if (config.AppShortName) {
|
|
63
|
+
// Use config's AppShortName
|
|
64
|
+
appJson = await fetchAppFromServer(config.AppShortName, options, config);
|
|
65
|
+
} else {
|
|
66
|
+
// Prompt
|
|
67
|
+
const inquirer = (await import('inquirer')).default;
|
|
68
|
+
const { choice } = await inquirer.prompt([{
|
|
69
|
+
type: 'list',
|
|
70
|
+
name: 'choice',
|
|
71
|
+
message: 'How would you like to clone?',
|
|
72
|
+
choices: [
|
|
73
|
+
{ name: 'From server (by app short name)', value: 'server' },
|
|
74
|
+
{ name: 'From a local JSON file', value: 'local' },
|
|
75
|
+
],
|
|
76
|
+
}]);
|
|
77
|
+
|
|
78
|
+
if (choice === 'server') {
|
|
79
|
+
const { appName } = await inquirer.prompt([{
|
|
80
|
+
type: 'input', name: 'appName',
|
|
81
|
+
message: 'App short name:',
|
|
82
|
+
validate: v => v.trim() ? true : 'App short name is required',
|
|
83
|
+
}]);
|
|
84
|
+
appJson = await fetchAppFromServer(appName, options, config);
|
|
85
|
+
} else {
|
|
86
|
+
const { filePath } = await inquirer.prompt([{
|
|
87
|
+
type: 'input', name: 'filePath',
|
|
88
|
+
message: 'Path to JSON file:',
|
|
89
|
+
validate: v => v.trim() ? true : 'File path is required',
|
|
90
|
+
}]);
|
|
91
|
+
const raw = await readFile(filePath, 'utf8');
|
|
92
|
+
appJson = JSON.parse(raw);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Validate structure
|
|
97
|
+
if (!appJson || !appJson.UID || !appJson.children) {
|
|
98
|
+
throw new Error('Invalid app JSON: missing UID or children');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
log.success(`Cloning "${appJson.Name}" (${appJson.ShortName})`);
|
|
102
|
+
|
|
103
|
+
// Ensure sensitive files are gitignored
|
|
104
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt']);
|
|
105
|
+
|
|
106
|
+
// Step 2: Update .dbo/config.json
|
|
107
|
+
await updateConfigWithApp({
|
|
108
|
+
AppID: appJson.AppID,
|
|
109
|
+
AppUID: appJson.UID,
|
|
110
|
+
AppName: appJson.Name,
|
|
111
|
+
AppShortName: appJson.ShortName,
|
|
112
|
+
});
|
|
113
|
+
log.dim(' Updated .dbo/config.json with app metadata');
|
|
114
|
+
|
|
115
|
+
// Step 3: Update package.json
|
|
116
|
+
await updatePackageJson(appJson, config);
|
|
117
|
+
|
|
118
|
+
// Step 4: Create default project directories + bin structure
|
|
119
|
+
for (const dir of DEFAULT_PROJECT_DIRS) {
|
|
120
|
+
await mkdir(dir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const bins = appJson.children.bin || [];
|
|
124
|
+
const structure = buildBinHierarchy(bins, appJson.AppID);
|
|
125
|
+
const createdDirs = await createDirectories(structure);
|
|
126
|
+
await saveStructureFile(structure);
|
|
127
|
+
|
|
128
|
+
const totalDirs = DEFAULT_PROJECT_DIRS.length + createdDirs.length;
|
|
129
|
+
log.success(`Created ${totalDirs} director${totalDirs === 1 ? 'y' : 'ies'}`);
|
|
130
|
+
for (const d of DEFAULT_PROJECT_DIRS) log.dim(` ${d}/`);
|
|
131
|
+
for (const d of createdDirs) log.dim(` ${d}/`);
|
|
132
|
+
|
|
133
|
+
// Step 4b: Determine placement preferences (from config or prompt)
|
|
134
|
+
const placementPrefs = await resolvePlacementPreferences(appJson, options);
|
|
135
|
+
|
|
136
|
+
// Ensure ServerTimezone is set in config (default: America/Los_Angeles for DBO.io)
|
|
137
|
+
let serverTz = config.ServerTimezone;
|
|
138
|
+
if (!serverTz || serverTz === 'UTC') {
|
|
139
|
+
serverTz = 'America/Los_Angeles';
|
|
140
|
+
await updateConfigWithApp({ ServerTimezone: serverTz });
|
|
141
|
+
log.dim(` Set ServerTimezone to ${serverTz} in .dbo/config.json`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Step 5: Process content → files + metadata
|
|
145
|
+
const contentRefs = await processContentEntries(
|
|
146
|
+
appJson.children.content || [],
|
|
147
|
+
structure,
|
|
148
|
+
options,
|
|
149
|
+
placementPrefs.contentPlacement,
|
|
150
|
+
serverTz,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Step 5b: Process media → download binary files + metadata
|
|
154
|
+
let mediaRefs = [];
|
|
155
|
+
const mediaEntries = appJson.children.media || [];
|
|
156
|
+
if (mediaEntries.length > 0) {
|
|
157
|
+
mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, placementPrefs.mediaPlacement, serverTz);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Step 6: Process other entities (not output, not bin, not content, not media)
|
|
161
|
+
const otherRefs = {};
|
|
162
|
+
for (const [entityName, entries] of Object.entries(appJson.children)) {
|
|
163
|
+
if (['bin', 'content', 'output', 'media'].includes(entityName)) continue;
|
|
164
|
+
if (!Array.isArray(entries)) continue;
|
|
165
|
+
|
|
166
|
+
const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
|
|
167
|
+
if (refs.length > 0) {
|
|
168
|
+
otherRefs[entityName] = refs;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Include media refs in otherRefs for app.json replacement
|
|
173
|
+
if (mediaRefs.length > 0) {
|
|
174
|
+
otherRefs.media = mediaRefs;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Step 7: Save app.json with references
|
|
178
|
+
await saveAppJson(appJson, contentRefs, otherRefs);
|
|
179
|
+
|
|
180
|
+
log.plain('');
|
|
181
|
+
log.success('Clone complete!');
|
|
182
|
+
log.dim(' app.json saved to project root');
|
|
183
|
+
log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve placement preferences from config or prompt the user.
|
|
188
|
+
* Returns { contentPlacement, mediaPlacement } where values are 'path'|'bin'|'ask'|null
|
|
189
|
+
*/
|
|
190
|
+
async function resolvePlacementPreferences(appJson, options) {
|
|
191
|
+
// Load saved preferences from config
|
|
192
|
+
const saved = await loadClonePlacement();
|
|
193
|
+
let contentPlacement = saved.contentPlacement;
|
|
194
|
+
let mediaPlacement = saved.mediaPlacement;
|
|
195
|
+
|
|
196
|
+
const hasContent = (appJson.children.content || []).length > 0;
|
|
197
|
+
const hasMedia = (appJson.children.media || []).length > 0;
|
|
198
|
+
|
|
199
|
+
// If -y flag, default to bin placement (no prompts)
|
|
200
|
+
if (options.yes) {
|
|
201
|
+
return {
|
|
202
|
+
contentPlacement: contentPlacement || 'bin',
|
|
203
|
+
mediaPlacement: mediaPlacement || 'bin',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// If both are already set in config, use them
|
|
208
|
+
if (contentPlacement && mediaPlacement) {
|
|
209
|
+
return { contentPlacement, mediaPlacement };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const inquirer = (await import('inquirer')).default;
|
|
213
|
+
const prompts = [];
|
|
214
|
+
|
|
215
|
+
if (!contentPlacement && hasContent) {
|
|
216
|
+
prompts.push({
|
|
217
|
+
type: 'list',
|
|
218
|
+
name: 'contentPlacement',
|
|
219
|
+
message: 'How should content files be placed?',
|
|
220
|
+
choices: [
|
|
221
|
+
{ name: 'Save all in BinID directory', value: 'bin' },
|
|
222
|
+
{ name: 'Save all in their specified Path directory', value: 'path' },
|
|
223
|
+
{ name: 'Ask for every file that has both', value: 'ask' },
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!mediaPlacement && hasMedia) {
|
|
229
|
+
prompts.push({
|
|
230
|
+
type: 'list',
|
|
231
|
+
name: 'mediaPlacement',
|
|
232
|
+
message: 'How should media files be placed?',
|
|
233
|
+
choices: [
|
|
234
|
+
{ name: 'Save all in BinID directory', value: 'bin' },
|
|
235
|
+
{ name: 'Save all in their specified FullPath directory', value: 'fullpath' },
|
|
236
|
+
{ name: 'Ask for every file that has both', value: 'ask' },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (prompts.length > 0) {
|
|
242
|
+
const answers = await inquirer.prompt(prompts);
|
|
243
|
+
contentPlacement = contentPlacement || answers.contentPlacement || 'bin';
|
|
244
|
+
mediaPlacement = mediaPlacement || answers.mediaPlacement || 'bin';
|
|
245
|
+
|
|
246
|
+
// Save to config for future clones
|
|
247
|
+
await saveClonePlacement({ mediaPlacement, contentPlacement });
|
|
248
|
+
log.dim(' Saved placement preferences to .dbo/config.json');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
contentPlacement: contentPlacement || 'bin',
|
|
253
|
+
mediaPlacement: mediaPlacement || 'bin',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Fetch app JSON from the server by AppShortName.
|
|
259
|
+
*/
|
|
260
|
+
async function fetchAppFromServer(appShortName, options, config) {
|
|
261
|
+
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
262
|
+
log.info(`Fetching app "${appShortName}" from server...`);
|
|
263
|
+
|
|
264
|
+
const result = await client.get('/api/o/object_lookup_app__CALL', {
|
|
265
|
+
'_filter:AppShortName': appShortName,
|
|
266
|
+
'_format': 'json_raw',
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const data = result.payload || result.data;
|
|
270
|
+
const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || []);
|
|
271
|
+
|
|
272
|
+
if (rows.length === 0) {
|
|
273
|
+
throw new Error(`No app found with ShortName "${appShortName}"`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
log.success(`Found app on server`);
|
|
277
|
+
return rows[0];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Create or update package.json with app metadata.
|
|
282
|
+
* Only populates fields that are not already set.
|
|
283
|
+
*/
|
|
284
|
+
async function updatePackageJson(appJson, config) {
|
|
285
|
+
const pkgPath = join(process.cwd(), 'package.json');
|
|
286
|
+
let pkg = {};
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
290
|
+
} catch {
|
|
291
|
+
// No package.json yet — start fresh
|
|
292
|
+
pkg = { private: true };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let changed = false;
|
|
296
|
+
|
|
297
|
+
if (!pkg.name && appJson.ShortName) {
|
|
298
|
+
pkg.name = appJson.ShortName;
|
|
299
|
+
changed = true;
|
|
300
|
+
}
|
|
301
|
+
if (!pkg.productName && appJson.Name) {
|
|
302
|
+
pkg.productName = appJson.Name;
|
|
303
|
+
changed = true;
|
|
304
|
+
}
|
|
305
|
+
if (!pkg.description && appJson.Description) {
|
|
306
|
+
pkg.description = appJson.Description;
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
if (!pkg.homepage && config.domain) {
|
|
310
|
+
pkg.homepage = `https://${config.domain}`;
|
|
311
|
+
changed = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Add deploy script if not already set
|
|
315
|
+
if (!pkg.scripts) pkg.scripts = {};
|
|
316
|
+
if (!pkg.scripts.deploy) {
|
|
317
|
+
pkg.scripts.deploy = "dbo push '.'";
|
|
318
|
+
changed = true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (changed) {
|
|
322
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
323
|
+
log.dim(' Updated package.json with app metadata');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Process content entries: write files + metadata, return reference map.
|
|
329
|
+
* Returns array of { uid, metaPath } for app.json reference replacement.
|
|
330
|
+
*/
|
|
331
|
+
async function processContentEntries(contents, structure, options, contentPlacement, serverTz) {
|
|
332
|
+
if (!contents || contents.length === 0) return [];
|
|
333
|
+
|
|
334
|
+
const refs = [];
|
|
335
|
+
const usedNames = new Map();
|
|
336
|
+
// Pre-set from config: 'path' or 'bin' skips prompts, 'ask' or null prompts per-file
|
|
337
|
+
const placementPreference = {
|
|
338
|
+
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
log.info(`Processing ${contents.length} content record(s)...`);
|
|
342
|
+
|
|
343
|
+
for (const record of contents) {
|
|
344
|
+
const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz);
|
|
345
|
+
if (ref) refs.push(ref);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return refs;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Process non-content, non-output entities.
|
|
353
|
+
* Only handles entries with a BinID.
|
|
354
|
+
*/
|
|
355
|
+
async function processGenericEntries(entityName, entries, structure, options, contentPlacement, serverTz) {
|
|
356
|
+
if (!entries || entries.length === 0) return [];
|
|
357
|
+
|
|
358
|
+
const refs = [];
|
|
359
|
+
const usedNames = new Map();
|
|
360
|
+
const placementPreference = {
|
|
361
|
+
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
362
|
+
};
|
|
363
|
+
let processed = 0;
|
|
364
|
+
|
|
365
|
+
for (const record of entries) {
|
|
366
|
+
if (!record.BinID) continue; // Skip entries without BinID
|
|
367
|
+
|
|
368
|
+
const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz);
|
|
369
|
+
if (ref) {
|
|
370
|
+
refs.push(ref);
|
|
371
|
+
processed++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (processed > 0) {
|
|
376
|
+
log.info(`Processed ${processed} ${entityName} record(s)`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return refs;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Process media entries: download binary files from server + create metadata.
|
|
384
|
+
* Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
|
|
385
|
+
*/
|
|
386
|
+
async function processMediaEntries(mediaRecords, structure, options, config, appShortName, mediaPlacement, serverTz) {
|
|
387
|
+
if (!mediaRecords || mediaRecords.length === 0) return [];
|
|
388
|
+
|
|
389
|
+
// Determine if we can download (need a server connection)
|
|
390
|
+
let canDownload = false;
|
|
391
|
+
let client = null;
|
|
392
|
+
|
|
393
|
+
if (!options.yes) {
|
|
394
|
+
const inquirer = (await import('inquirer')).default;
|
|
395
|
+
const { download } = await inquirer.prompt([{
|
|
396
|
+
type: 'confirm',
|
|
397
|
+
name: 'download',
|
|
398
|
+
message: `${mediaRecords.length} media file(s) need to be downloaded from the server. Attempt download now?`,
|
|
399
|
+
default: true,
|
|
400
|
+
}]);
|
|
401
|
+
canDownload = download;
|
|
402
|
+
} else {
|
|
403
|
+
canDownload = true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (canDownload) {
|
|
407
|
+
try {
|
|
408
|
+
client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
409
|
+
await client.getDomain(); // Verify domain is configured
|
|
410
|
+
} catch {
|
|
411
|
+
log.warn('No domain configured — skipping media downloads');
|
|
412
|
+
canDownload = false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!canDownload) {
|
|
417
|
+
log.warn(`Skipping ${mediaRecords.length} media file(s) — download not attempted`);
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const refs = [];
|
|
422
|
+
const usedNames = new Map();
|
|
423
|
+
// Map config values: 'fullpath' → 'path' (used internally), 'bin' → 'bin', 'ask' → null
|
|
424
|
+
const placementPreference = {
|
|
425
|
+
value: mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null,
|
|
426
|
+
};
|
|
427
|
+
let downloaded = 0;
|
|
428
|
+
let failed = 0;
|
|
429
|
+
|
|
430
|
+
log.info(`Downloading ${mediaRecords.length} media file(s)...`);
|
|
431
|
+
|
|
432
|
+
for (const record of mediaRecords) {
|
|
433
|
+
const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
|
|
434
|
+
const name = sanitizeFilename(filename.replace(/\.[^.]+$/, '')); // base name without extension
|
|
435
|
+
const ext = (record.Extension || 'bin').toLowerCase();
|
|
436
|
+
|
|
437
|
+
// Determine target directory (default: bins/ for items without explicit placement)
|
|
438
|
+
let dir = BINS_DIR;
|
|
439
|
+
const hasBinID = record.BinID && structure[record.BinID];
|
|
440
|
+
const hasFullPath = record.FullPath && typeof record.FullPath === 'string';
|
|
441
|
+
|
|
442
|
+
// Parse directory from FullPath: strip leading / and remove filename
|
|
443
|
+
// FullPath like /media/operator/app/assets/gfx/logo.png → media/operator/app/assets/gfx/
|
|
444
|
+
// These are valid project-relative paths (media/, dir/ are real directories)
|
|
445
|
+
let fullPathDir = null;
|
|
446
|
+
if (hasFullPath) {
|
|
447
|
+
const stripped = record.FullPath.replace(/^\/+/, '');
|
|
448
|
+
const lastSlash = stripped.lastIndexOf('/');
|
|
449
|
+
fullPathDir = lastSlash >= 0 ? stripped.substring(0, lastSlash) : BINS_DIR;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (hasBinID && fullPathDir && fullPathDir !== '.' && !options.yes) {
|
|
453
|
+
if (placementPreference.value) {
|
|
454
|
+
dir = placementPreference.value === 'path'
|
|
455
|
+
? fullPathDir
|
|
456
|
+
: resolveBinPath(record.BinID, structure);
|
|
457
|
+
} else {
|
|
458
|
+
const binPath = resolveBinPath(record.BinID, structure);
|
|
459
|
+
const binName = getBinName(record.BinID, structure);
|
|
460
|
+
const inquirer = (await import('inquirer')).default;
|
|
461
|
+
|
|
462
|
+
const { placement } = await inquirer.prompt([{
|
|
463
|
+
type: 'list',
|
|
464
|
+
name: 'placement',
|
|
465
|
+
message: `Where do you want me to place ${filename}?`,
|
|
466
|
+
choices: [
|
|
467
|
+
{ name: `Into the FullPath of ${fullPathDir}`, value: 'path' },
|
|
468
|
+
{ name: `Into the BinID of ${record.BinID} (${binName} → ${binPath})`, value: 'bin' },
|
|
469
|
+
{ name: `Place this and all further files by FullPath`, value: 'all_path' },
|
|
470
|
+
{ name: `Place this and all further files by BinID`, value: 'all_bin' },
|
|
471
|
+
],
|
|
472
|
+
}]);
|
|
473
|
+
|
|
474
|
+
if (placement === 'all_path') {
|
|
475
|
+
placementPreference.value = 'path';
|
|
476
|
+
dir = fullPathDir;
|
|
477
|
+
} else if (placement === 'all_bin') {
|
|
478
|
+
placementPreference.value = 'bin';
|
|
479
|
+
dir = binPath;
|
|
480
|
+
} else {
|
|
481
|
+
dir = placement === 'path' ? fullPathDir : binPath;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
} else if (hasBinID) {
|
|
485
|
+
dir = resolveBinPath(record.BinID, structure);
|
|
486
|
+
} else if (fullPathDir && fullPathDir !== BINS_DIR) {
|
|
487
|
+
dir = fullPathDir;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
491
|
+
if (!dir) dir = BINS_DIR;
|
|
492
|
+
await mkdir(dir, { recursive: true });
|
|
493
|
+
|
|
494
|
+
// Deduplicate using full filename (name.ext) so different formats don't collide
|
|
495
|
+
// e.g. KaTeX_SansSerif-Italic.woff and KaTeX_SansSerif-Italic.woff2 are separate files
|
|
496
|
+
const fileKey = `${dir}/${name}.${ext}`;
|
|
497
|
+
const fileCount = usedNames.get(fileKey) || 0;
|
|
498
|
+
usedNames.set(fileKey, fileCount + 1);
|
|
499
|
+
const dedupName = fileCount > 0 ? `${name}-${fileCount + 1}` : name;
|
|
500
|
+
const finalFilename = `${dedupName}.${ext}`;
|
|
501
|
+
// Metadata: use name.ext as base to avoid collisions between formats
|
|
502
|
+
// e.g. KaTeX_SansSerif-Italic.woff.metadata.json vs KaTeX_SansSerif-Italic.woff2.metadata.json
|
|
503
|
+
const filePath = join(dir, finalFilename);
|
|
504
|
+
const metaPath = join(dir, `${finalFilename}.metadata.json`);
|
|
505
|
+
|
|
506
|
+
// Download the file
|
|
507
|
+
try {
|
|
508
|
+
const buffer = await client.getBuffer(`/api/media/${record.UID}`);
|
|
509
|
+
await writeFile(filePath, buffer);
|
|
510
|
+
const sizeKB = (buffer.length / 1024).toFixed(1);
|
|
511
|
+
log.success(`Downloaded ${filePath} (${sizeKB} KB)`);
|
|
512
|
+
downloaded++;
|
|
513
|
+
} catch (err) {
|
|
514
|
+
log.warn(`Failed to download ${filename}`);
|
|
515
|
+
log.dim(` UID: ${record.UID}`);
|
|
516
|
+
log.dim(` URL: /api/media/${record.UID}`);
|
|
517
|
+
if (record.FullPath) log.dim(` FullPath: ${record.FullPath}`);
|
|
518
|
+
log.dim(` Error: ${err.message}`);
|
|
519
|
+
failed++;
|
|
520
|
+
continue; // Skip metadata if download failed
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Build metadata
|
|
524
|
+
const meta = {};
|
|
525
|
+
for (const [key, value] of Object.entries(record)) {
|
|
526
|
+
if (key === 'children') continue;
|
|
527
|
+
meta[key] = value;
|
|
528
|
+
}
|
|
529
|
+
meta._entity = 'media';
|
|
530
|
+
meta._mediaFile = `@${finalFilename}`;
|
|
531
|
+
|
|
532
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
533
|
+
log.dim(` → ${metaPath}`);
|
|
534
|
+
|
|
535
|
+
// Set file timestamps from server dates
|
|
536
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
537
|
+
try {
|
|
538
|
+
await setFileTimestamps(filePath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
539
|
+
await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
540
|
+
} catch { /* non-critical */ }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
refs.push({ uid: record.UID, metaPath });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
log.info(`Media: ${downloaded} downloaded, ${failed} failed`);
|
|
547
|
+
return refs;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Process a single record: determine directory, write content file + metadata.
|
|
552
|
+
* Returns { uid, metaPath } or null.
|
|
553
|
+
*/
|
|
554
|
+
async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz) {
|
|
555
|
+
let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
|
|
556
|
+
const ext = (record.Extension || 'txt').toLowerCase();
|
|
557
|
+
|
|
558
|
+
// Avoid double extension: if name already ends with .ext, strip it
|
|
559
|
+
const extWithDot = `.${ext}`;
|
|
560
|
+
if (name.toLowerCase().endsWith(extWithDot)) {
|
|
561
|
+
name = name.substring(0, name.length - extWithDot.length);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Determine target directory (default: bins/ for items without explicit placement)
|
|
565
|
+
let dir = BINS_DIR;
|
|
566
|
+
const hasBinID = record.BinID && structure[record.BinID];
|
|
567
|
+
const hasPath = record.Path && typeof record.Path === 'string' && record.Path.trim();
|
|
568
|
+
|
|
569
|
+
if (hasBinID && hasPath && !options.yes) {
|
|
570
|
+
// Check for a saved "all" preference
|
|
571
|
+
if (placementPreference.value) {
|
|
572
|
+
dir = placementPreference.value === 'path'
|
|
573
|
+
? record.Path
|
|
574
|
+
: resolveBinPath(record.BinID, structure);
|
|
575
|
+
} else {
|
|
576
|
+
// Both BinID and Path — prompt user
|
|
577
|
+
const binPath = resolveBinPath(record.BinID, structure);
|
|
578
|
+
const binName = getBinName(record.BinID, structure);
|
|
579
|
+
const inquirer = (await import('inquirer')).default;
|
|
580
|
+
|
|
581
|
+
const { placement } = await inquirer.prompt([{
|
|
582
|
+
type: 'list',
|
|
583
|
+
name: 'placement',
|
|
584
|
+
message: `Where do you want me to place ${name}.${ext}?`,
|
|
585
|
+
choices: [
|
|
586
|
+
{ name: `Into the Path of ${record.Path}`, value: 'path' },
|
|
587
|
+
{ name: `Into the BinID of ${record.BinID} (${binName} → ${binPath})`, value: 'bin' },
|
|
588
|
+
{ name: `Place this and all further files by Path`, value: 'all_path' },
|
|
589
|
+
{ name: `Place this and all further files by BinID`, value: 'all_bin' },
|
|
590
|
+
],
|
|
591
|
+
}]);
|
|
592
|
+
|
|
593
|
+
if (placement === 'all_path') {
|
|
594
|
+
placementPreference.value = 'path';
|
|
595
|
+
dir = record.Path;
|
|
596
|
+
} else if (placement === 'all_bin') {
|
|
597
|
+
placementPreference.value = 'bin';
|
|
598
|
+
dir = binPath;
|
|
599
|
+
} else {
|
|
600
|
+
dir = placement === 'path' ? record.Path : binPath;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} else if (hasBinID) {
|
|
604
|
+
dir = resolveBinPath(record.BinID, structure);
|
|
605
|
+
} else if (hasPath) {
|
|
606
|
+
dir = record.Path;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Clean up directory path
|
|
610
|
+
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
611
|
+
if (!dir) dir = BINS_DIR;
|
|
612
|
+
|
|
613
|
+
// Handle path that contains a filename
|
|
614
|
+
const pathExt = extname(dir);
|
|
615
|
+
if (pathExt) {
|
|
616
|
+
// Path includes a filename — use the directory part
|
|
617
|
+
dir = dir.substring(0, dir.lastIndexOf('/')) || BINS_DIR;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
await mkdir(dir, { recursive: true });
|
|
621
|
+
|
|
622
|
+
// Deduplicate filenames
|
|
623
|
+
const nameKey = `${dir}/${name}`;
|
|
624
|
+
const count = usedNames.get(nameKey) || 0;
|
|
625
|
+
usedNames.set(nameKey, count + 1);
|
|
626
|
+
const finalName = count > 0 ? `${name}-${count + 1}` : name;
|
|
627
|
+
|
|
628
|
+
// Write content file if Content column has data
|
|
629
|
+
const contentValue = record.Content;
|
|
630
|
+
const hasContent = contentValue && (
|
|
631
|
+
(typeof contentValue === 'object' && contentValue.value) ||
|
|
632
|
+
(typeof contentValue === 'string' && contentValue.length > 0)
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const fileName = `${finalName}.${ext}`;
|
|
636
|
+
const filePath = join(dir, fileName);
|
|
637
|
+
const metaPath = join(dir, `${finalName}.metadata.json`);
|
|
638
|
+
|
|
639
|
+
if (hasContent) {
|
|
640
|
+
const decoded = resolveContentValue(contentValue);
|
|
641
|
+
if (decoded) {
|
|
642
|
+
await writeFile(filePath, decoded);
|
|
643
|
+
log.success(`Saved ${filePath}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Build metadata
|
|
648
|
+
const meta = {};
|
|
649
|
+
for (const [key, value] of Object.entries(record)) {
|
|
650
|
+
if (key === 'children') continue; // Don't store nested children in metadata
|
|
651
|
+
|
|
652
|
+
if (key === 'Content' && hasContent) {
|
|
653
|
+
// Replace with @filename reference
|
|
654
|
+
meta[key] = `@${fileName}`;
|
|
655
|
+
} else if (value && typeof value === 'object' && value.encoding === 'base64') {
|
|
656
|
+
// Other base64 columns — decode and store inline or as reference
|
|
657
|
+
const decoded = resolveContentValue(value);
|
|
658
|
+
if (decoded && decoded.length > 200) {
|
|
659
|
+
// Large value: save as separate file
|
|
660
|
+
const colExt = guessExtensionForColumn(key);
|
|
661
|
+
const colFileName = `${finalName}-${key.toLowerCase()}.${colExt}`;
|
|
662
|
+
const colFilePath = join(dir, colFileName);
|
|
663
|
+
await writeFile(colFilePath, decoded);
|
|
664
|
+
meta[key] = `@${colFileName}`;
|
|
665
|
+
if (!meta._contentColumns) meta._contentColumns = [];
|
|
666
|
+
meta._contentColumns.push(key);
|
|
667
|
+
} else {
|
|
668
|
+
meta[key] = decoded;
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
meta[key] = value;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
meta._entity = entityName;
|
|
676
|
+
if (hasContent && !meta._contentColumns) {
|
|
677
|
+
meta._contentColumns = ['Content'];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
681
|
+
log.dim(` → ${metaPath}`);
|
|
682
|
+
|
|
683
|
+
// Set file timestamps from server dates
|
|
684
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
685
|
+
try {
|
|
686
|
+
if (hasContent) await setFileTimestamps(filePath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
687
|
+
await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
688
|
+
} catch { /* non-critical */ }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return { uid: record.UID, metaPath };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Guess file extension for a column name.
|
|
696
|
+
*/
|
|
697
|
+
function guessExtensionForColumn(columnName) {
|
|
698
|
+
const lower = columnName.toLowerCase();
|
|
699
|
+
if (lower.includes('css')) return 'css';
|
|
700
|
+
if (lower.includes('js') || lower.includes('script')) return 'js';
|
|
701
|
+
if (lower.includes('html') || lower.includes('template')) return 'html';
|
|
702
|
+
if (lower.includes('sql') || lower === 'customsql') return 'sql';
|
|
703
|
+
if (lower.includes('xml')) return 'xml';
|
|
704
|
+
if (lower.includes('json')) return 'json';
|
|
705
|
+
if (lower.includes('md') || lower.includes('markdown')) return 'md';
|
|
706
|
+
return 'txt';
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Save app.json to project root with @ references replacing processed entries.
|
|
711
|
+
*/
|
|
712
|
+
async function saveAppJson(appJson, contentRefs, otherRefs) {
|
|
713
|
+
const output = { ...appJson };
|
|
714
|
+
output.children = { ...appJson.children };
|
|
715
|
+
|
|
716
|
+
// Replace content array with references
|
|
717
|
+
if (contentRefs.length > 0) {
|
|
718
|
+
output.children.content = contentRefs.map(r => `@${r.metaPath}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Replace other entity arrays with references
|
|
722
|
+
for (const [entityName, refs] of Object.entries(otherRefs)) {
|
|
723
|
+
if (refs.length > 0) {
|
|
724
|
+
// Mix: referenced entries become strings, unreferenced stay as objects
|
|
725
|
+
const original = appJson.children[entityName] || [];
|
|
726
|
+
const refUids = new Set(refs.map(r => r.uid));
|
|
727
|
+
const refMap = Object.fromEntries(refs.map(r => [r.uid, r.metaPath]));
|
|
728
|
+
|
|
729
|
+
output.children[entityName] = original.map(entry => {
|
|
730
|
+
if (refUids.has(entry.UID)) {
|
|
731
|
+
return `@${refMap[entry.UID]}`;
|
|
732
|
+
}
|
|
733
|
+
return entry; // Keep unreferenced entries as-is
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Bins stay as-is (directory structure, no metadata files)
|
|
739
|
+
// Output stays as-is (ignored for now)
|
|
740
|
+
|
|
741
|
+
await writeFile('app.json', JSON.stringify(output, null, 2) + '\n');
|
|
742
|
+
}
|