@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,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
+ }