@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 +27 -5
- package/package.json +1 -1
- package/src/commands/add.js +8 -0
- package/src/commands/clone.js +106 -14
- package/src/commands/init.js +9 -1
- package/src/commands/login.js +69 -0
- package/src/commands/push.js +87 -15
- package/src/lib/delta.js +14 -1
- package/src/lib/diff.js +4 -2
- package/src/lib/ticketing.js +6 -3
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 (
|
|
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
package/src/commands/add.js
CHANGED
|
@@ -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);
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
975
|
-
return
|
|
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
|
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -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')
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
416
|
-
let result = await client.postUrlEncoded('/api/input/submit', body);
|
|
444
|
+
let result;
|
|
417
445
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
53
|
+
if (!ig.ignores(relPath)) results.push(fullPath);
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
package/src/lib/ticketing.js
CHANGED
|
@@ -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
|
}
|