@dboio/cli 0.8.0 → 0.8.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.
package/README.md CHANGED
@@ -185,8 +185,26 @@ A gitignore-style file in the project root that controls which files and directo
185
185
 
186
186
  **Used by:**
187
187
  - `dbo init` — scaffold empty-check (determines if directory is "effectively empty")
188
- - `dbo add .` — directory scanning (which files/dirs to skip)
189
- - `dbo push` — metadata file discovery (which dirs to skip)
188
+ - `dbo add .` — directory scanning (which files/dirs to skip); also checked in single-file mode (`dbo add file.html`)
189
+ - `dbo push` — metadata file discovery: skips matching `.metadata.json` / `_output~*.json` files AND any record whose companion content file (`@reference`) matches an ignore pattern
190
+
191
+ **Bypass:** Use `dbo input -d '...'` to submit expressions for a file that would otherwise be ignored — `dbo input` never does file discovery so `.dboignore` does not apply.
192
+
193
+ **Pattern examples:**
194
+
195
+ ```gitignore
196
+ # Ignore all SQL companion files (output records with CustomSQL)
197
+ **/*.CustomSQL.sql
198
+
199
+ # Ignore a specific record (by metadata file path)
200
+ Bins/app/my-draft-page.metadata.json
201
+
202
+ # Ignore an entire directory
203
+ Bins/staging/
204
+
205
+ # Ignore all content in Bins/ (still push entity-dir records like Extensions/)
206
+ Bins/
207
+ ```
190
208
 
191
209
  **Syntax:** Same as `.gitignore` — glob patterns, `#` comments, blank lines, negation with `!`, directory-only patterns with trailing `/`.
192
210
 
@@ -250,7 +268,7 @@ dbo init --scaffold --yes # scaffold dirs non-intera
250
268
  | `--domain <host>` | DBO instance domain |
251
269
  | `--username <user>` | DBO username (stored for login default) |
252
270
  | `--force` | Overwrite existing configuration. Triggers a domain-change confirmation prompt when the new domain differs from the project reference domain |
253
- | `--app <shortName>` | App short name (triggers clone after init) |
271
+ | `--app <shortName>` | App short name (triggers clone after init). Prompts for password and authenticates automatically before fetching app data from the server |
254
272
  | `--clone` | Clone the app after initialization |
255
273
  | `-g, --global` | Install Claude commands globally (`~/.claude/commands/`) |
256
274
  | `--local` | Install Claude commands to project (`.claude/commands/`) |
@@ -305,12 +323,12 @@ The project's reference domain is stored in `app.json._domain` (committed to git
305
323
 
306
324
  #### What clone does
307
325
 
308
- 1. **Loads app JSON** — from a local file, server API, or interactive prompt
326
+ 1. **Loads app JSON** — from a local file, server API, or interactive prompt. A spinner shows progress while fetching from the server (responses can be slow as the JSON is assembled on demand)
309
327
  2. **Updates `.dbo/config.json`** — saves `AppID`, `AppUID`, `AppName`, `AppShortName`, `AppModifyKey` (if the app is locked), and `cloneSource` (the source used for this clone)
310
328
  3. **Updates `package.json`** — populates `name`, `productName`, `description`, `homepage`, and `deploy` script
311
329
  4. **Creates directories** — processes `children.bin` to build the directory hierarchy based on `ParentBinID` relationships
312
330
  5. **Saves `.dbo/structure.json`** — maps BinIDs to directory paths for file placement
313
- 6. **Writes content files** — decodes base64 content, creates `*.metadata.json` + content files in the correct bin directory
331
+ 6. **Writes content files** — decodes base64 content, creates `*.metadata.json` + content files in the correct bin directory. When a record's `Extension` field is empty, prompts you to choose from `css`, `js`, `html`, `xml`, `txt`, `md`, `cs`, `json`, `sql` (or skip to keep no extension). The chosen extension is saved back into the `metadata.json` `Extension` field
314
332
  7. **Downloads media files** — fetches binary files (images, CSS, fonts) from the server via `/api/media/{uid}` and saves with metadata
315
333
  8. **Processes entity-dir records** — entities matching project directories (`extension`, `app_version`, `data_source`, `site`, `group`, `integration`, `automation`) are saved as `.metadata.json` files in their corresponding directory (e.g., `Extensions/`, `Data Sources/`)
316
334
  9. **Processes other entities** — remaining entities with a `BinID` are placed in the corresponding bin directory
@@ -1015,6 +1033,8 @@ After accepting changes, file modification times are synced to the server's `_La
1015
1033
 
1016
1034
  Push local files back to DBO.io using metadata from a previous pull. This is the counterpart to `dbo content pull` and `dbo output --save`.
1017
1035
 
1036
+ Files and records matching `.dboignore` patterns are skipped — both by metadata file path (e.g. `*.metadata.json`) and by companion content file path (e.g. a record whose `@Content` points to an ignored `.sql` file). To push an ignored file directly, use `dbo input -d '...'` with an explicit expression.
1037
+
1018
1038
  #### Round-trip workflow
1019
1039
 
1020
1040
  ```bash
@@ -1188,6 +1208,8 @@ The next `dbo push` processes all pending deletions before pushing file changes.
1188
1208
 
1189
1209
  Add a new file to DBO.io by creating a server record. Similar to `git add`, this registers a local file with the server.
1190
1210
 
1211
+ Files matching `.dboignore` patterns are skipped — both in directory-scan mode (`dbo add .`) and single-file mode (`dbo add file.html`). Use `dbo input` to create a record for an ignored file directly.
1212
+
1191
1213
  #### Single file
1192
1214
 
1193
1215
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,6 +73,14 @@ async function detectDocumentationFile(filePath) {
73
73
  }
74
74
 
75
75
  async function addSingleFile(filePath, client, options, batchDefaults) {
76
+ // Check .dboignore before doing any processing
77
+ const ig = await loadIgnore();
78
+ const relPath = relative(process.cwd(), filePath).replace(/\\/g, '/');
79
+ if (ig.ignores(relPath)) {
80
+ log.dim(`Skipped (dboignored): ${relPath}`);
81
+ return null;
82
+ }
83
+
76
84
  const dir = dirname(filePath);
77
85
  const ext = extname(filePath);
78
86
  const base = basename(filePath, ext);
@@ -444,10 +444,22 @@ export const cloneCommand = new Command('clone')
444
444
  async function resolveAppSource(source, options, config) {
445
445
  if (source) {
446
446
  if (source.startsWith('http://') || source.startsWith('https://')) {
447
- log.info(`Fetching app JSON from ${source}...`);
448
- const res = await fetch(source);
449
- if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${source}`);
450
- return await res.json();
447
+ const ora = (await import('ora')).default;
448
+ const spinner = ora(`Fetching app JSON from ${source}...`).start();
449
+ let res;
450
+ try {
451
+ res = await fetch(source);
452
+ } catch (err) {
453
+ spinner.fail(`Failed to fetch from ${source}`);
454
+ throw err;
455
+ }
456
+ if (!res.ok) {
457
+ spinner.fail(`HTTP ${res.status} fetching ${source}`);
458
+ throw new Error(`HTTP ${res.status} fetching ${source}`);
459
+ }
460
+ const json = await res.json();
461
+ spinner.succeed('Loaded app JSON');
462
+ return json;
451
463
  }
452
464
  log.info(`Loading app JSON from ${source}...`);
453
465
  const raw = await readFile(source, 'utf8');
@@ -460,10 +472,22 @@ async function resolveAppSource(source, options, config) {
460
472
  if (storedSource && storedSource !== 'default') {
461
473
  // Stored source is a local file path or URL — reuse it
462
474
  if (storedSource.startsWith('http://') || storedSource.startsWith('https://')) {
463
- log.info(`Fetching app JSON from ${storedSource} (stored source)...`);
464
- const res = await fetch(storedSource);
465
- if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${storedSource}`);
466
- return await res.json();
475
+ const ora = (await import('ora')).default;
476
+ const spinner = ora(`Fetching app JSON from ${storedSource} (stored source)...`).start();
477
+ let res;
478
+ try {
479
+ res = await fetch(storedSource);
480
+ } catch (err) {
481
+ spinner.fail(`Failed to fetch from ${storedSource}`);
482
+ throw err;
483
+ }
484
+ if (!res.ok) {
485
+ spinner.fail(`HTTP ${res.status} fetching ${storedSource}`);
486
+ throw new Error(`HTTP ${res.status} fetching ${storedSource}`);
487
+ }
488
+ const json = await res.json();
489
+ spinner.succeed('Loaded app JSON');
490
+ return json;
467
491
  }
468
492
  if (await fileExists(storedSource)) {
469
493
  log.info(`Loading app JSON from ${storedSource} (stored source)...`);
@@ -960,19 +984,45 @@ async function resolvePlacementPreferences(appJson, options) {
960
984
  */
961
985
  async function fetchAppFromServer(appShortName, options, config) {
962
986
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
963
- log.info(`Fetching app "${appShortName}" from server...`);
964
987
 
965
- const result = await client.get(`/api/app/object/${appShortName}`);
988
+ const ora = (await import('ora')).default;
989
+ const spinner = ora(`Fetching app "${appShortName}" from server...`).start();
990
+
991
+ let result;
992
+ try {
993
+ result = await client.get(`/api/app/object/${appShortName}`);
994
+ } catch (err) {
995
+ spinner.fail(`Failed to fetch app "${appShortName}"`);
996
+ throw err;
997
+ }
966
998
 
967
999
  const data = result.payload || result.data;
968
- const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || []);
969
1000
 
970
- if (rows.length === 0) {
1001
+ // Handle all response shapes:
1002
+ // 1. Array of rows: [{ UID, ShortName, ... }]
1003
+ // 2. Object with Rows key: { Rows: [...] }
1004
+ // 3. Single app object: { UID, ShortName, children, ... }
1005
+ let appRecord;
1006
+ if (Array.isArray(data)) {
1007
+ appRecord = data.length > 0 ? data[0] : null;
1008
+ } else if (data?.Rows?.length > 0) {
1009
+ appRecord = data.Rows[0];
1010
+ } else if (data?.rows?.length > 0) {
1011
+ appRecord = data.rows[0];
1012
+ } else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
1013
+ // Single app object returned directly as payload
1014
+ appRecord = data;
1015
+ } else {
1016
+ appRecord = null;
1017
+ }
1018
+
1019
+ if (!appRecord) {
1020
+ spinner.fail(`No app found with ShortName "${appShortName}"`);
971
1021
  throw new Error(`No app found with ShortName "${appShortName}"`);
972
1022
  }
973
1023
 
974
- log.success(`Found app on server`);
975
- return rows[0];
1024
+ spinner.succeed(`Found app on server`);
1025
+ return appRecord;
976
1026
  }
977
1027
 
978
1028
  /**
@@ -2114,6 +2164,41 @@ async function processRecord(entityName, record, structure, options, usedNames,
2114
2164
  }
2115
2165
  // If still no extension, ext remains '' (no extension)
2116
2166
 
2167
+ // If no extension determined and Content column has data, prompt user to choose one
2168
+ if (!ext && !options.yes && record.Content) {
2169
+ const cv = record.Content;
2170
+ const hasContentData = cv && (
2171
+ (typeof cv === 'object' && cv.value !== null && cv.value !== undefined) ||
2172
+ (typeof cv === 'string' && cv.length > 0)
2173
+ );
2174
+ if (hasContentData) {
2175
+ // Decode a snippet for preview
2176
+ let snippet = '';
2177
+ try {
2178
+ const decoded = resolveContentValue(cv);
2179
+ if (decoded) {
2180
+ snippet = decoded.substring(0, 80).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
2181
+ if (decoded.length > 80) snippet += '...';
2182
+ }
2183
+ } catch { /* ignore decode errors */ }
2184
+ const preview = snippet ? ` (${snippet})` : '';
2185
+ const VALID_CONTENT_EXTENSIONS = ['css', 'js', 'html', 'xml', 'txt', 'md', 'cs', 'json', 'sql'];
2186
+ const inquirer = (await import('inquirer')).default;
2187
+ const { chosenExt } = await inquirer.prompt([{
2188
+ type: 'list',
2189
+ name: 'chosenExt',
2190
+ message: `No extension found for "${record.Name || record.UID}". Choose a file extension for the Content:${preview}`,
2191
+ choices: [
2192
+ ...VALID_CONTENT_EXTENSIONS.map(e => ({ name: `.${e}`, value: e })),
2193
+ { name: 'No extension (skip)', value: '' },
2194
+ ],
2195
+ }]);
2196
+ if (chosenExt) {
2197
+ ext = chosenExt;
2198
+ }
2199
+ }
2200
+ }
2201
+
2117
2202
  // Avoid double extension: if name already ends with .ext, strip it
2118
2203
  if (ext) {
2119
2204
  const extWithDot = `.${ext}`;
@@ -2320,6 +2405,13 @@ async function processRecord(entityName, record, structure, options, usedNames,
2320
2405
  meta._contentColumns = ['Content'];
2321
2406
  }
2322
2407
 
2408
+ // If the extension picker chose an extension (record.Extension was null),
2409
+ // set it in metadata only — not in the record — so the baseline preserves
2410
+ // the server's null and push detects the change.
2411
+ if (ext && !record.Extension) {
2412
+ meta.Extension = ext;
2413
+ }
2414
+
2323
2415
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
2324
2416
  log.dim(` → ${metaPath}`);
2325
2417
 
@@ -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')
@@ -165,7 +166,7 @@ export const initCommand = new Command('init')
165
166
  logScaffoldResult(result);
166
167
  }
167
168
 
168
- // Clone if requested
169
+ // Clone if requested — requires authentication first
169
170
  if (options.clone || options.app) {
170
171
  let appShortName = options.app;
171
172
  if (!appShortName) {
@@ -177,6 +178,13 @@ export const initCommand = new Command('init')
177
178
  }]);
178
179
  appShortName = appName;
179
180
  }
181
+
182
+ // Authenticate before fetching app data from the server
183
+ if (!options.nonInteractive) {
184
+ log.info('Login required to fetch app data from the server.');
185
+ await performLogin(domain, username);
186
+ }
187
+
180
188
  const { performClone } = await import('./clone.js');
181
189
  await performClone(null, { app: appShortName, domain });
182
190
  }
@@ -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')
@@ -12,6 +12,7 @@ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/m
12
12
  import { resolveTransactionKey } from '../lib/transaction-key.js';
13
13
  import { setFileTimestamps } from '../lib/timestamps.js';
14
14
  import { findMetadataFiles } from '../lib/diff.js';
15
+ import { loadIgnore } from '../lib/ignore.js';
15
16
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
16
17
 
17
18
  /**
@@ -178,7 +179,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
178
179
  * Push all records found in a directory (recursive)
179
180
  */
180
181
  async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
181
- const metaFiles = await findMetadataFiles(dirPath);
182
+ const ig = await loadIgnore();
183
+ const metaFiles = await findMetadataFiles(dirPath, ig);
182
184
 
183
185
  if (metaFiles.length === 0) {
184
186
  log.warn(`No .metadata.json files found in "${dirPath}".`);
@@ -238,6 +240,26 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
238
240
  }
239
241
  if (missingFiles) { skipped++; continue; }
240
242
 
243
+ // Check if any companion content file is ignored by .dboignore
244
+ {
245
+ const metaDir = dirname(metaPath);
246
+ let contentIgnored = false;
247
+ for (const col of contentCols) {
248
+ const ref = meta[col];
249
+ if (ref && String(ref).startsWith('@')) {
250
+ const refFile = String(ref).substring(1);
251
+ const contentPath = resolveAtReference(refFile, metaDir);
252
+ const relContent = relative(process.cwd(), contentPath).replace(/\\/g, '/');
253
+ if (ig.ignores(relContent)) {
254
+ log.dim(` Skipped (dboignored): ${basename(metaPath)}`);
255
+ contentIgnored = true;
256
+ break;
257
+ }
258
+ }
259
+ }
260
+ if (contentIgnored) { skipped++; continue; }
261
+ }
262
+
241
263
  // Detect changed columns (delta detection)
242
264
  let changedColumns = null;
243
265
  if (baseline) {
@@ -263,7 +285,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
263
285
 
264
286
  // Pre-flight ticket validation (only if no --ticket flag)
265
287
  if (!options.ticket && toPush.length > 0) {
266
- const ticketCheck = await checkStoredTicket(options);
288
+ const recordSummary = toPush.map(r => basename(r.metaPath, '.metadata.json')).join(', ');
289
+ const ticketCheck = await checkStoredTicket(options, `${toPush.length} record(s): ${recordSummary}`);
267
290
  if (ticketCheck.cancel) {
268
291
  log.info('Submission cancelled');
269
292
  return;
@@ -397,31 +420,61 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
397
420
  }
398
421
  }
399
422
 
400
- if (dataExprs.length === 0) {
423
+ // Detect media file upload (binary file changed for media entity)
424
+ const isMediaUpload = entity === 'media' && meta._mediaFile
425
+ && String(meta._mediaFile).startsWith('@')
426
+ && changedColumns?.includes('_mediaFile');
427
+
428
+ if (dataExprs.length === 0 && !isMediaUpload) {
401
429
  log.warn(`Nothing to push for ${basename(metaPath)}`);
402
430
  return false;
403
431
  }
404
432
 
405
- const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
433
+ const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)${isMediaUpload ? ' + media file' : ''}` : `${dataExprs.length} field(s)`;
406
434
  log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
407
435
 
408
436
  // Apply stored ticket if no --ticket flag
409
- await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
437
+ const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
410
438
 
411
439
  const extraParams = { '_confirm': options.confirm };
412
440
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
441
+ else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
413
442
  if (modifyKey) extraParams['_modify_key'] = modifyKey;
414
443
 
415
- let body = await buildInputBody(dataExprs, extraParams);
416
- let result = await client.postUrlEncoded('/api/input/submit', body);
444
+ let result;
417
445
 
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);
446
+ if (isMediaUpload) {
447
+ // Media file upload: use multipart/form-data
448
+ const mediaFileName = String(meta._mediaFile).substring(1);
449
+ const mediaFilePath = resolveAtReference(mediaFileName, metaDir);
450
+
451
+ // Ensure at least one data expression to identify the row for the server
452
+ if (dataExprs.length === 0 && meta.Filename) {
453
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.Filename=${meta.Filename}`);
454
+ }
455
+
456
+ const fields = { ...extraParams };
457
+ for (const expr of dataExprs) {
458
+ const eqIdx = expr.indexOf('=');
459
+ if (eqIdx !== -1) {
460
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
461
+ }
462
+ }
463
+
464
+ const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaFileName }];
465
+ result = await client.postMultipart('/api/input/submit', fields, files);
466
+ } else {
467
+ let body = await buildInputBody(dataExprs, extraParams);
424
468
  result = await client.postUrlEncoded('/api/input/submit', body);
469
+
470
+ // Reactive ModifyKey retry — server rejected because key wasn't set locally
471
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
472
+ const retryMK = await handleModifyKeyError();
473
+ if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
474
+ extraParams['_modify_key'] = retryMK.modifyKey;
475
+ body = await buildInputBody(dataExprs, extraParams);
476
+ result = await client.postUrlEncoded('/api/input/submit', body);
477
+ }
425
478
  }
426
479
 
427
480
  // Retry with prompted params if needed (ticket, user, repo mismatch)
@@ -445,8 +498,22 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
445
498
  const params = retryResult.retryParams || retryResult;
446
499
  Object.assign(extraParams, params);
447
500
 
448
- body = await buildInputBody(dataExprs, extraParams);
449
- result = await client.postUrlEncoded('/api/input/submit', body);
501
+ if (isMediaUpload) {
502
+ const mediaFileName = String(meta._mediaFile).substring(1);
503
+ const mediaFilePath = resolveAtReference(mediaFileName, metaDir);
504
+ const fields = { ...extraParams };
505
+ for (const expr of dataExprs) {
506
+ const eqIdx = expr.indexOf('=');
507
+ if (eqIdx !== -1) {
508
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
509
+ }
510
+ }
511
+ const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaFileName }];
512
+ result = await client.postMultipart('/api/input/submit', fields, files);
513
+ } else {
514
+ const body = await buildInputBody(dataExprs, extraParams);
515
+ result = await client.postUrlEncoded('/api/input/submit', body);
516
+ }
450
517
  }
451
518
 
452
519
  formatResponse(result, { json: options.json, jq: options.jq });
@@ -486,6 +553,11 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
486
553
  await setFileTimestamps(contentPath, meta._CreatedOn, updated, serverTz);
487
554
  }
488
555
  }
556
+ // Update media file mtime too
557
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
558
+ const mediaPath = join(dirname(metaPath), String(meta._mediaFile).substring(1));
559
+ await setFileTimestamps(mediaPath, meta._CreatedOn, updated, serverTz);
560
+ }
489
561
  }
490
562
  }
491
563
  }
package/src/lib/delta.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile } from 'fs/promises';
1
+ import { readFile, stat } from 'fs/promises';
2
2
  import { join, dirname } from 'path';
3
3
  import { log } from './logger.js';
4
4
 
@@ -132,6 +132,19 @@ export async function detectChangedColumns(metaPath, baseline) {
132
132
  }
133
133
  }
134
134
 
135
+ // Check _mediaFile for binary file changes (media entities)
136
+ if (metadata._mediaFile && isReference(metadata._mediaFile)) {
137
+ const mediaPath = resolveReferencePath(metadata._mediaFile, metaDir);
138
+ try {
139
+ const mediaStat = await stat(mediaPath);
140
+ const metaStat = await stat(metaPath);
141
+ // Media file modified more recently than metadata = local change
142
+ if (mediaStat.mtimeMs > metaStat.mtimeMs + 2000) {
143
+ changedColumns.push('_mediaFile');
144
+ }
145
+ } catch { /* missing file, skip */ }
146
+ }
147
+
135
148
  return changedColumns;
136
149
  }
137
150
 
package/src/lib/diff.js CHANGED
@@ -45,10 +45,12 @@ export async function findMetadataFiles(dir, ig) {
45
45
  if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
46
46
  results.push(...await findMetadataFiles(fullPath, ig));
47
47
  } else if (entry.name.endsWith('.metadata.json')) {
48
- results.push(fullPath);
48
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
49
+ if (!ig.ignores(relPath)) results.push(fullPath);
49
50
  } else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
50
51
  // Output hierarchy files: _output~<name>~<uid>.json and nested entity files
51
- results.push(fullPath);
52
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
53
+ if (!ig.ignores(relPath)) results.push(fullPath);
52
54
  }
53
55
  }
54
56
 
@@ -145,7 +145,7 @@ export function buildTicketExpression(entity, rowId, ticketId) {
145
145
  *
146
146
  * @param {Object} options - Command options (checks options.ticket for flag override)
147
147
  */
148
- export async function checkStoredTicket(options) {
148
+ export async function checkStoredTicket(options, context = '') {
149
149
  // --ticket flag takes precedence; skip stored-ticket prompt
150
150
  if (options.ticket) {
151
151
  return { useTicket: false, clearTicket: false, cancel: false };
@@ -162,11 +162,12 @@ export async function checkStoredTicket(options) {
162
162
  return { useTicket: true, clearTicket: false, cancel: false };
163
163
  }
164
164
 
165
+ const suffix = context ? ` (${context})` : '';
165
166
  const inquirer = (await import('inquirer')).default;
166
167
  const { action } = await inquirer.prompt([{
167
168
  type: 'list',
168
169
  name: 'action',
169
- message: `Use stored Ticket ID "${data.ticket_id}" for this submission?`,
170
+ message: `Use stored Ticket ID "${data.ticket_id}" for this submission?${suffix}`,
170
171
  choices: [
171
172
  { name: `Yes, use "${data.ticket_id}"`, value: 'use' },
172
173
  { name: 'Use a different ticket for this submission only', value: 'alt_once' },
@@ -213,7 +214,7 @@ export async function checkStoredTicket(options) {
213
214
  * @param {string|null} [sessionOverride] - One-time ticket override from pre-flight prompt
214
215
  */
215
216
  export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, uid, options, sessionOverride = null) {
216
- if (options.ticket) return; // --ticket flag takes precedence
217
+ if (options.ticket) return null; // --ticket flag takes precedence
217
218
 
218
219
  const recordTicket = await getRecordTicket(uid);
219
220
  const globalTicket = await getGlobalTicket();
@@ -223,5 +224,7 @@ export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, ui
223
224
  const ticketExpr = buildTicketExpression(entity, rowId, ticketToUse);
224
225
  dataExprs.push(ticketExpr);
225
226
  log.dim(` Applying ticket: ${ticketToUse}`);
227
+ return ticketToUse;
226
228
  }
229
+ return null;
227
230
  }