@dboio/cli 0.8.0 → 0.9.2

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.
@@ -178,7 +178,7 @@ export const deployCommand = new Command('deploy')
178
178
  log.success(`${entryName} deployed`);
179
179
  } else {
180
180
  log.error(`${entryName} failed`);
181
- formatResponse(result, { json: options.json });
181
+ formatResponse(result, { json: options.json, verbose: options.verbose });
182
182
  if (!options.all) process.exit(1);
183
183
  }
184
184
  }
@@ -7,6 +7,7 @@ import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
7
7
  import { createDboignore, loadIgnore } from '../lib/ignore.js';
8
8
  import { log } from '../lib/logger.js';
9
9
  import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
10
+ import { performLogin } from './login.js';
10
11
 
11
12
  export const initCommand = new Command('init')
12
13
  .description('Initialize DBO CLI configuration for the current directory')
@@ -19,8 +20,9 @@ export const initCommand = new Command('init')
19
20
  .option('--non-interactive', 'Skip all interactive prompts')
20
21
  .option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
21
22
  .option('--local', 'Install Claude commands to project directory (.claude/commands/)')
22
- .option('--scaffold', 'Create standard project directories (App Versions, Automations, Bins, …)')
23
+ .option('--scaffold', 'Create standard project directories (app_version, automation, bins, …)')
23
24
  .option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
25
+ .option('--media-placement <placement>', 'Set media placement when cloning: fullpath or binpath (default: bin)')
24
26
  .action(async (options) => {
25
27
  // Merge --yes into nonInteractive
26
28
  if (options.yes) options.nonInteractive = true;
@@ -97,7 +99,7 @@ export const initCommand = new Command('init')
97
99
  }
98
100
 
99
101
  // Ensure sensitive files are gitignored
100
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
102
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', 'trash/']);
101
103
 
102
104
  const createdIgnore = await createDboignore();
103
105
  if (createdIgnore) log.dim(' Created .dboignore');
@@ -165,7 +167,7 @@ export const initCommand = new Command('init')
165
167
  logScaffoldResult(result);
166
168
  }
167
169
 
168
- // Clone if requested
170
+ // Clone if requested — requires authentication first
169
171
  if (options.clone || options.app) {
170
172
  let appShortName = options.app;
171
173
  if (!appShortName) {
@@ -177,8 +179,15 @@ export const initCommand = new Command('init')
177
179
  }]);
178
180
  appShortName = appName;
179
181
  }
182
+
183
+ // Authenticate before fetching app data from the server
184
+ if (!options.nonInteractive) {
185
+ log.info('Login required to fetch app data from the server.');
186
+ await performLogin(domain, username);
187
+ }
188
+
180
189
  const { performClone } = await import('./clone.js');
181
- await performClone(null, { app: appShortName, domain });
190
+ await performClone(null, { app: appShortName, domain, mediaPlacement: options.mediaPlacement });
182
191
  }
183
192
 
184
193
  // Offer Claude Code integration (skip in non-interactive mode)
@@ -124,7 +124,7 @@ export const inputCommand = new Command('input')
124
124
  result = await client.postMultipart('/api/input/submit', fields, files);
125
125
  }
126
126
 
127
- formatResponse(result, { json: options.json, jq: options.jq });
127
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
128
128
  if (!result.successful) process.exit(1);
129
129
  } else {
130
130
  // URL-encoded mode
@@ -153,7 +153,7 @@ export const inputCommand = new Command('input')
153
153
  result = await client.postUrlEncoded('/api/input/submit', body);
154
154
  }
155
155
 
156
- formatResponse(result, { json: options.json, jq: options.jq });
156
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
157
157
  if (!result.successful) process.exit(1);
158
158
  }
159
159
  } catch (err) {
@@ -3,6 +3,75 @@ import { loadConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../l
3
3
  import { DboClient } from '../lib/client.js';
4
4
  import { log } from '../lib/logger.js';
5
5
 
6
+ /**
7
+ * Perform authentication against a DBO instance.
8
+ * Prompts for missing credentials interactively.
9
+ * Returns true on success, throws on failure.
10
+ *
11
+ * @param {string|null} domain - Override domain (null = use config)
12
+ * @param {string|null} knownUsername - Pre-filled username (will prompt if null)
13
+ */
14
+ export async function performLogin(domain, knownUsername) {
15
+ const config = await loadConfig();
16
+ const client = new DboClient({ domain });
17
+
18
+ let username = knownUsername || config.username;
19
+ let password;
20
+
21
+ // Interactive prompt for missing credentials
22
+ const inquirer = (await import('inquirer')).default;
23
+ const answers = await inquirer.prompt([
24
+ { type: 'input', name: 'username', message: 'Username (email):', default: username || undefined, when: !username },
25
+ { type: 'password', name: 'password', message: 'Password:', mask: '*' },
26
+ ]);
27
+ username = username || answers.username;
28
+ password = answers.password;
29
+
30
+ const params = new URLSearchParams();
31
+ params.append('_username', username);
32
+ params.append('_password', password);
33
+
34
+ const result = await client.postUrlEncoded('/api/authenticate', params.toString());
35
+
36
+ if (!result.successful) {
37
+ throw new Error('Authentication failed');
38
+ }
39
+
40
+ log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
41
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
42
+
43
+ // Fetch and store user info (non-critical)
44
+ try {
45
+ const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_format': 'json_raw' });
46
+ const userData = userResult.payload || userResult.data;
47
+ const rows = Array.isArray(userData) ? userData : (userData?.Rows || userData?.rows || []);
48
+ if (rows.length > 0) {
49
+ const row = rows[0];
50
+ const userId = row.ID || row.id || row.UserID || row.userId;
51
+ if (userId) {
52
+ await saveUserInfo({ userId: String(userId) });
53
+ log.dim(` User ID: ${userId}`);
54
+ }
55
+ const firstName = row.FirstName || row.firstname || row.first_name;
56
+ const lastName = row.LastName || row.lastname || row.last_name;
57
+ const email = row.Email || row.email;
58
+ const profile = {};
59
+ if (firstName) profile.FirstName = firstName;
60
+ if (lastName) profile.LastName = lastName;
61
+ if (email) profile.Email = email;
62
+ if (Object.keys(profile).length > 0) {
63
+ await saveUserProfile(profile);
64
+ if (firstName || lastName) log.dim(` Name: ${[firstName, lastName].filter(Boolean).join(' ')}`);
65
+ if (email) log.dim(` Email: ${email}`);
66
+ }
67
+ }
68
+ } catch {
69
+ log.dim(' Could not retrieve user info (non-critical)');
70
+ }
71
+
72
+ return true;
73
+ }
74
+
6
75
  export const loginCommand = new Command('login')
7
76
  .description('Authenticate with a DBO.io instance')
8
77
  .option('-u, --username <value>', 'Username')
@@ -5,6 +5,7 @@ import { loadConfig } from '../lib/config.js';
5
5
  import { formatError } from '../lib/formatter.js';
6
6
  import { saveToDisk } from '../lib/save-to-disk.js';
7
7
  import { log } from '../lib/logger.js';
8
+ import { renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
8
9
 
9
10
  function collect(value, previous) {
10
11
  return previous.concat([value]);
@@ -1,8 +1,8 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, stat, writeFile } from 'fs/promises';
2
+ import { readFile, stat, writeFile, rename as fsRename, mkdir } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
5
+ import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
@@ -11,8 +11,11 @@ import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, cl
11
11
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
12
12
  import { resolveTransactionKey } from '../lib/transaction-key.js';
13
13
  import { setFileTimestamps } from '../lib/timestamps.js';
14
+ import { stripUidFromFilename, renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
14
15
  import { findMetadataFiles } from '../lib/diff.js';
16
+ import { loadIgnore } from '../lib/ignore.js';
15
17
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
18
+ import { BINS_DIR } from '../lib/structure.js';
16
19
 
17
20
  /**
18
21
  * Resolve an @reference file path to an absolute filesystem path.
@@ -90,6 +93,8 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
90
93
  const extraParams = { '_confirm': options.confirm || 'true' };
91
94
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
92
95
  if (modifyKey) extraParams['_modify_key'] = modifyKey;
96
+ const cachedUser2 = getSessionUserOverride();
97
+ if (cachedUser2) extraParams['_OverrideUserID'] = cachedUser2;
93
98
 
94
99
  const body = await buildInputBody([entry.expression], extraParams);
95
100
 
@@ -123,7 +128,7 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
123
128
  deletedUids.push(entry.UID);
124
129
  } else {
125
130
  log.error(` Failed to delete "${entry.name}"`);
126
- formatResponse(retryResponse, { json: options.json, jq: options.jq });
131
+ formatResponse(retryResponse, { json: options.json, jq: options.jq, verbose: options.verbose });
127
132
  remaining.push(entry);
128
133
  }
129
134
  } else if (result.successful) {
@@ -131,7 +136,7 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
131
136
  deletedUids.push(entry.UID);
132
137
  } else {
133
138
  log.error(` Failed to delete "${entry.name}"`);
134
- formatResponse(result, { json: options.json, jq: options.jq });
139
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
135
140
  remaining.push(entry);
136
141
  }
137
142
  } catch (err) {
@@ -140,6 +145,12 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
140
145
  }
141
146
  }
142
147
 
148
+ // Move __WILL_DELETE__ files to Trash/ for successfully deleted records
149
+ for (const entry of sync.delete) {
150
+ if (!deletedUids.includes(entry.UID)) continue;
151
+ await moveWillDeleteToTrash(entry);
152
+ }
153
+
143
154
  // Remove edit entries for successfully deleted records (spec requirement)
144
155
  if (deletedUids.length > 0) {
145
156
  sync.edit = (sync.edit || []).filter(e => !deletedUids.includes(e.UID));
@@ -154,6 +165,65 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
154
165
  }
155
166
  }
156
167
 
168
+ /**
169
+ * Move __WILL_DELETE__-prefixed files associated with a sync entry to Trash/.
170
+ */
171
+ async function moveWillDeleteToTrash(entry) {
172
+ if (!entry.metaPath) return;
173
+
174
+ const trashDir = join(process.cwd(), 'trash');
175
+ await mkdir(trashDir, { recursive: true });
176
+
177
+ const metaDir = dirname(entry.metaPath);
178
+ const metaBase = basename(entry.metaPath);
179
+ const willDeleteMeta = join(metaDir, `__WILL_DELETE__${metaBase}`);
180
+
181
+ const filesToMove = [];
182
+
183
+ try {
184
+ await stat(willDeleteMeta);
185
+ filesToMove.push({ from: willDeleteMeta, to: join(trashDir, metaBase) });
186
+
187
+ // Read the __WILL_DELETE__ metadata to find associated content files
188
+ const rawMeta = await readFile(willDeleteMeta, 'utf8');
189
+ const deletedMeta = JSON.parse(rawMeta);
190
+ for (const col of (deletedMeta._contentColumns || [])) {
191
+ const ref = deletedMeta[col];
192
+ if (ref && String(ref).startsWith('@')) {
193
+ const refFile = String(ref).substring(1);
194
+ const willDeleteContent = join(metaDir, `__WILL_DELETE__${refFile}`);
195
+ try {
196
+ await stat(willDeleteContent);
197
+ filesToMove.push({ from: willDeleteContent, to: join(trashDir, refFile) });
198
+ } catch {}
199
+ }
200
+ }
201
+ if (deletedMeta._mediaFile && String(deletedMeta._mediaFile).startsWith('@')) {
202
+ const refFile = String(deletedMeta._mediaFile).substring(1);
203
+ const willDeleteMedia = join(metaDir, `__WILL_DELETE__${refFile}`);
204
+ try {
205
+ await stat(willDeleteMedia);
206
+ filesToMove.push({ from: willDeleteMedia, to: join(trashDir, refFile) });
207
+ } catch {}
208
+ }
209
+ } catch {
210
+ // No __WILL_DELETE__ metadata file — nothing to move
211
+ return;
212
+ }
213
+
214
+ for (const { from, to } of filesToMove) {
215
+ // Handle Trash collision: append timestamp
216
+ let destPath = to;
217
+ try { await stat(destPath); destPath = `${to}.${Date.now()}`; } catch {}
218
+ try {
219
+ await fsRename(from, destPath);
220
+ log.dim(` Moved to trash: ${basename(destPath)}`);
221
+ } catch (err) {
222
+ log.warn(` Could not move to trash: ${from} — ${err.message}`);
223
+ }
224
+ }
225
+ }
226
+
157
227
  /**
158
228
  * Push a single file using its companion .metadata.json
159
229
  */
@@ -178,7 +248,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
178
248
  * Push all records found in a directory (recursive)
179
249
  */
180
250
  async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
181
- const metaFiles = await findMetadataFiles(dirPath);
251
+ const ig = await loadIgnore();
252
+ const metaFiles = await findMetadataFiles(dirPath, ig);
182
253
 
183
254
  if (metaFiles.length === 0) {
184
255
  log.warn(`No .metadata.json files found in "${dirPath}".`);
@@ -238,6 +309,26 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
238
309
  }
239
310
  if (missingFiles) { skipped++; continue; }
240
311
 
312
+ // Check if any companion content file is ignored by .dboignore
313
+ {
314
+ const metaDir = dirname(metaPath);
315
+ let contentIgnored = false;
316
+ for (const col of contentCols) {
317
+ const ref = meta[col];
318
+ if (ref && String(ref).startsWith('@')) {
319
+ const refFile = String(ref).substring(1);
320
+ const contentPath = resolveAtReference(refFile, metaDir);
321
+ const relContent = relative(process.cwd(), contentPath).replace(/\\/g, '/');
322
+ if (ig.ignores(relContent)) {
323
+ log.dim(` Skipped (dboignored): ${basename(metaPath)}`);
324
+ contentIgnored = true;
325
+ break;
326
+ }
327
+ }
328
+ }
329
+ if (contentIgnored) { skipped++; continue; }
330
+ }
331
+
241
332
  // Detect changed columns (delta detection)
242
333
  let changedColumns = null;
243
334
  if (baseline) {
@@ -263,7 +354,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
263
354
 
264
355
  // Pre-flight ticket validation (only if no --ticket flag)
265
356
  if (!options.ticket && toPush.length > 0) {
266
- const ticketCheck = await checkStoredTicket(options);
357
+ const recordSummary = toPush.map(r => basename(r.metaPath, '.metadata.json')).join(', ');
358
+ const ticketCheck = await checkStoredTicket(options, `${toPush.length} record(s): ${recordSummary}`);
267
359
  if (ticketCheck.cancel) {
268
360
  log.info('Submission cancelled');
269
361
  return;
@@ -397,31 +489,66 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
397
489
  }
398
490
  }
399
491
 
400
- if (dataExprs.length === 0) {
492
+ // Detect media file upload (binary file changed for media entity)
493
+ const isMediaUpload = entity === 'media' && meta._mediaFile
494
+ && String(meta._mediaFile).startsWith('@')
495
+ && changedColumns?.includes('_mediaFile');
496
+
497
+ if (dataExprs.length === 0 && !isMediaUpload) {
401
498
  log.warn(`Nothing to push for ${basename(metaPath)}`);
402
499
  return false;
403
500
  }
404
501
 
405
- const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
502
+ const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)${isMediaUpload ? ' + media file' : ''}` : `${dataExprs.length} field(s)`;
406
503
  log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
407
504
 
408
505
  // Apply stored ticket if no --ticket flag
409
- await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
506
+ const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
410
507
 
411
508
  const extraParams = { '_confirm': options.confirm };
412
509
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
510
+ else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
413
511
  if (modifyKey) extraParams['_modify_key'] = modifyKey;
512
+ const cachedUser = getSessionUserOverride();
513
+ if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
514
+
515
+ let result;
516
+
517
+ if (isMediaUpload) {
518
+ // Media file upload: use multipart/form-data
519
+ const mediaLocalName = String(meta._mediaFile).substring(1);
520
+ const mediaFilePath = resolveAtReference(mediaLocalName, metaDir);
521
+ const mediaUid = meta.UID || meta._id;
522
+ const mediaUploadName = meta.Filename
523
+ || stripUidFromFilename(mediaLocalName, mediaUid);
524
+
525
+ // Ensure at least one data expression to identify the row for the server
526
+ if (dataExprs.length === 0 && meta.Filename) {
527
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.Filename=${meta.Filename}`);
528
+ }
414
529
 
415
- let body = await buildInputBody(dataExprs, extraParams);
416
- let result = await client.postUrlEncoded('/api/input/submit', body);
530
+ const fields = { ...extraParams };
531
+ for (const expr of dataExprs) {
532
+ const eqIdx = expr.indexOf('=');
533
+ if (eqIdx !== -1) {
534
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
535
+ }
536
+ }
417
537
 
418
- // Reactive ModifyKey retry server rejected because key wasn't set locally
419
- if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
420
- const retryMK = await handleModifyKeyError();
421
- if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
422
- extraParams['_modify_key'] = retryMK.modifyKey;
423
- body = await buildInputBody(dataExprs, extraParams);
538
+ const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaUploadName }];
539
+ result = await client.postMultipart('/api/input/submit', fields, files);
540
+ } else {
541
+ let body = await buildInputBody(dataExprs, extraParams);
424
542
  result = await client.postUrlEncoded('/api/input/submit', body);
543
+
544
+ // Reactive ModifyKey retry — server rejected because key wasn't set locally
545
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
546
+ const retryMK = await handleModifyKeyError();
547
+ if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
548
+ extraParams['_modify_key'] = retryMK.modifyKey;
549
+ body = await buildInputBody(dataExprs, extraParams);
550
+ result = await client.postUrlEncoded('/api/input/submit', body);
551
+ }
425
552
  }
426
553
 
427
554
  // Retry with prompted params if needed (ticket, user, repo mismatch)
@@ -445,11 +572,28 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
445
572
  const params = retryResult.retryParams || retryResult;
446
573
  Object.assign(extraParams, params);
447
574
 
448
- body = await buildInputBody(dataExprs, extraParams);
449
- result = await client.postUrlEncoded('/api/input/submit', body);
575
+ if (isMediaUpload) {
576
+ const retryLocalName = String(meta._mediaFile).substring(1);
577
+ const retryFilePath = resolveAtReference(retryLocalName, metaDir);
578
+ const retryUid = meta.UID || meta._id;
579
+ const retryUploadName = meta.Filename
580
+ || stripUidFromFilename(retryLocalName, retryUid);
581
+ const fields = { ...extraParams };
582
+ for (const expr of dataExprs) {
583
+ const eqIdx = expr.indexOf('=');
584
+ if (eqIdx !== -1) {
585
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
586
+ }
587
+ }
588
+ const files = [{ fieldName: 'file', filePath: retryFilePath, fileName: retryUploadName }];
589
+ result = await client.postMultipart('/api/input/submit', fields, files);
590
+ } else {
591
+ const body = await buildInputBody(dataExprs, extraParams);
592
+ result = await client.postUrlEncoded('/api/input/submit', body);
593
+ }
450
594
  }
451
595
 
452
- formatResponse(result, { json: options.json, jq: options.jq });
596
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
453
597
 
454
598
  // Update metadata on disk if path was changed
455
599
  if (metaUpdated) {
@@ -464,6 +608,25 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
464
608
  // Clean up per-record ticket on success
465
609
  await clearRecordTicket(uid || id);
466
610
 
611
+ // Post-UID rename: if the record lacked a UID and the server returned one
612
+ try {
613
+ const editResults2 = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
614
+ const addResults2 = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
615
+ const allResults = [...editResults2, ...addResults2];
616
+ if (allResults.length > 0 && !meta.UID) {
617
+ const serverUID = allResults[0].UID;
618
+ if (serverUID && !hasUidInFilename(basename(metaPath), serverUID)) {
619
+ const config2 = await loadConfig();
620
+ const renameResult = await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
621
+ if (renameResult.newMetaPath !== metaPath) {
622
+ log.success(` Renamed to ~${serverUID} convention`);
623
+ // Update metaPath reference for subsequent timestamp operations
624
+ // (metaPath is const, but timestamp update below re-reads from meta)
625
+ }
626
+ }
627
+ }
628
+ } catch { /* non-critical rename */ }
629
+
467
630
  // Update file timestamps from server response
468
631
  try {
469
632
  const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
@@ -486,6 +649,11 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
486
649
  await setFileTimestamps(contentPath, meta._CreatedOn, updated, serverTz);
487
650
  }
488
651
  }
652
+ // Update media file mtime too
653
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
654
+ const mediaPath = join(dirname(metaPath), String(meta._mediaFile).substring(1));
655
+ await setFileTimestamps(mediaPath, meta._CreatedOn, updated, serverTz);
656
+ }
489
657
  }
490
658
  }
491
659
  }
@@ -497,29 +665,29 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
497
665
  /**
498
666
  * Normalize a local file path for comparison with server-side Path.
499
667
  *
500
- * The Bins/ directory is used for local file organization and does not reflect
668
+ * The bins/ directory is used for local file organization and does not reflect
501
669
  * the actual server-side serving path. This function strips organizational prefixes
502
670
  * to enable correct path comparison during push operations.
503
671
  *
504
672
  * **Directory Structure:**
505
- * - `Bins/` — Local organizational root (always stripped)
506
- * - `Bins/app/` — Special case: the "app" subdirectory is also stripped because it's
507
- * purely organizational. Files in Bins/app/ are served from the app root without
673
+ * - `bins/` — Local organizational root (always stripped)
674
+ * - `bins/app/` — Special case: the "app" subdirectory is also stripped because it's
675
+ * purely organizational. Files in bins/app/ are served from the app root without
508
676
  * the "app/" prefix on the server.
509
- * - `Bins/custom_name/` — Custom bin directories (tpl/, ticket_test/, etc.) are
677
+ * - `bins/custom_name/` — Custom bin directories (tpl/, ticket_test/, etc.) are
510
678
  * preserved because they represent actual bin hierarchies that serve from their
511
679
  * directory name.
512
680
  *
513
681
  * **Why "app/" is special:**
514
- * The main app bin is placed in Bins/app/ locally for organization, but server-side
682
+ * The main app bin is placed in bins/app/ locally for organization, but server-side
515
683
  * these files are served from the root path (no "app/" prefix). Other custom bins
516
684
  * like "tpl/" maintain their directory name in the serving path.
517
685
  *
518
686
  * Examples:
519
- * "Bins/app/assets/css/file.css" → "assets/css/file.css" (strips Bins/app/)
520
- * "Bins/tpl/header.html" → "tpl/header.html" (preserves tpl/)
521
- * "Bins/assets/css/file.css" → "assets/css/file.css" (strips Bins/ only)
522
- * "Sites/MySite/content/page.html" → "Sites/MySite/content/page.html" (unchanged)
687
+ * "bins/app/assets/css/file.css" → "assets/css/file.css" (strips bins/app/)
688
+ * "bins/tpl/header.html" → "tpl/header.html" (preserves tpl/)
689
+ * "bins/assets/css/file.css" → "assets/css/file.css" (strips bins/ only)
690
+ * "site/MySite/content/page.html" → "site/MySite/content/page.html" (unchanged)
523
691
  *
524
692
  * @param {string} localPath - Relative path from project root
525
693
  * @returns {string} - Normalized path for comparison with metadata Path column
@@ -531,10 +699,10 @@ function normalizePathForComparison(localPath) {
531
699
  // Strip leading and trailing slashes
532
700
  const cleaned = normalized.replace(/^\/+|\/+$/g, '');
533
701
 
534
- // Check if path starts with "Bins/" organizational directory
535
- if (cleaned.startsWith('Bins/')) {
536
- // Remove "Bins/" prefix (length = 5)
537
- const withoutBins = cleaned.substring(5);
702
+ // Check if path starts with bins/ organizational directory
703
+ const binsPrefix = BINS_DIR + '/';
704
+ if (cleaned.startsWith(binsPrefix)) {
705
+ const withoutBins = cleaned.substring(binsPrefix.length);
538
706
 
539
707
  // Special case: strip "app/" organizational subdirectory
540
708
  // This is the only special subdirectory - all others (tpl/, assets/, etc.) are preserved
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, unlink, stat, rm as fsRm } from 'fs/promises';
2
+ import { readFile, unlink, stat, rm as fsRm, rename } from 'fs/promises';
3
3
  import { join, dirname, basename, extname } from 'path';
4
4
  import { log } from '../lib/logger.js';
5
5
  import { formatError } from '../lib/formatter.js';
@@ -12,6 +12,7 @@ export const rmCommand = new Command('rm')
12
12
  .argument('<path>', 'File, metadata.json, or directory to remove')
13
13
  .option('-f, --force', 'Skip confirmation prompts')
14
14
  .option('--keep-local', 'Only stage server deletion, do not delete local files')
15
+ .option('--hard', 'Immediately delete local files (no Trash; legacy behavior)')
15
16
  .action(async (targetPath, options) => {
16
17
  try {
17
18
  const pathStat = await stat(targetPath).catch(() => null);
@@ -113,21 +114,35 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
113
114
  }
114
115
  }
115
116
 
116
- // Stage deletion
117
+ // Stage deletion (include metaPath for Trash workflow in push.js)
117
118
  const expression = `RowID:del${rowId};entity:${entity}=true`;
118
- await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
119
+ await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
119
120
  log.success(` Staged: ${displayName} → ${expression}`);
120
121
 
121
122
  // Remove from app.json
122
123
  await removeAppJsonReference(metaPath);
123
124
 
124
- // Delete local files
125
+ // Handle local files
125
126
  if (!options.keepLocal) {
126
- for (const f of localFiles) {
127
- try {
128
- await unlink(f);
129
- log.dim(` Deleted ${f}`);
130
- } catch { /* file may not exist */ }
127
+ if (options.hard) {
128
+ // --hard: original behavior — immediate delete
129
+ for (const f of localFiles) {
130
+ try { await unlink(f); log.dim(` Deleted ${f}`); } catch {}
131
+ }
132
+ } else {
133
+ // Default: rename to __WILL_DELETE__ prefix
134
+ for (const f of localFiles) {
135
+ const fDir = dirname(f);
136
+ const fName = basename(f);
137
+ if (fName.startsWith('__WILL_DELETE__')) continue; // idempotency guard
138
+ const willDeletePath = join(fDir, `__WILL_DELETE__${fName}`);
139
+ try {
140
+ await rename(f, willDeletePath);
141
+ log.dim(` Staged for delete: ${willDeletePath}`);
142
+ } catch (err) {
143
+ log.warn(` Could not rename ${f}: ${err.message}`);
144
+ }
145
+ }
131
146
  }
132
147
  }
133
148
 
@@ -196,18 +211,35 @@ async function rmFile(filePath, options) {
196
211
  }
197
212
 
198
213
  const expression = `RowID:del${rowId};entity:${entity}=true`;
199
- await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
214
+ await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
200
215
  log.success(`Staged deletion: ${expression}`);
201
216
 
202
217
  await removeAppJsonReference(metaPath);
203
218
 
204
219
  if (!options.keepLocal) {
205
- for (const f of localFiles) {
206
- try {
207
- await unlink(f);
208
- log.dim(` Deleted ${f}`);
209
- } catch {
210
- log.warn(` Could not delete ${f} (may not exist)`);
220
+ if (options.hard) {
221
+ // --hard: original behavior — immediate delete
222
+ for (const f of localFiles) {
223
+ try {
224
+ await unlink(f);
225
+ log.dim(` Deleted ${f}`);
226
+ } catch {
227
+ log.warn(` Could not delete ${f} (may not exist)`);
228
+ }
229
+ }
230
+ } else {
231
+ // Default: rename to __WILL_DELETE__ prefix
232
+ for (const f of localFiles) {
233
+ const fDir = dirname(f);
234
+ const fName = basename(f);
235
+ if (fName.startsWith('__WILL_DELETE__')) continue; // idempotency guard
236
+ const willDeletePath = join(fDir, `__WILL_DELETE__${fName}`);
237
+ try {
238
+ await rename(f, willDeletePath);
239
+ log.dim(` Staged for delete: ${willDeletePath}`);
240
+ } catch (err) {
241
+ log.warn(` Could not rename ${f}: ${err.message}`);
242
+ }
211
243
  }
212
244
  }
213
245
  }