@dboio/cli 0.6.13 → 0.7.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 +201 -4
- package/package.json +1 -1
- package/src/commands/clone.js +664 -27
- package/src/commands/content.js +1 -1
- package/src/commands/deploy.js +1 -1
- package/src/commands/init.js +67 -3
- package/src/commands/login.js +4 -9
- package/src/commands/output.js +56 -3
- package/src/commands/pull.js +3 -3
- package/src/commands/push.js +9 -9
- package/src/commands/status.js +0 -1
- package/src/lib/config.js +74 -8
- package/src/lib/diff.js +5 -1
- package/src/lib/domain-guard.js +95 -0
- package/src/lib/input-parser.js +87 -38
- package/src/lib/scaffold.js +62 -0
- package/src/lib/structure.js +16 -0
package/src/commands/content.js
CHANGED
|
@@ -116,7 +116,7 @@ const deployCmd = new Command('deploy')
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
119
|
-
const errorResult = await checkSubmitErrors(result);
|
|
119
|
+
const errorResult = await checkSubmitErrors(result, { rowUid: uid });
|
|
120
120
|
if (errorResult) {
|
|
121
121
|
if (errorResult.skipRecord || errorResult.skipAll) {
|
|
122
122
|
log.info('Submission cancelled');
|
package/src/commands/deploy.js
CHANGED
|
@@ -156,7 +156,7 @@ export const deployCommand = new Command('deploy')
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
159
|
-
const errorResult = await checkSubmitErrors(result);
|
|
159
|
+
const errorResult = await checkSubmitErrors(result, { rowUid: uid });
|
|
160
160
|
if (errorResult) {
|
|
161
161
|
if (errorResult.skipRecord) {
|
|
162
162
|
log.warn(` Skipping "${entryName}"`);
|
package/src/commands/init.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { access } from 'fs/promises';
|
|
2
|
+
import { access, readdir } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset } from '../lib/config.js';
|
|
4
|
+
import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
|
|
5
5
|
import { installOrUpdateClaudeCommands } from './install.js';
|
|
6
|
+
import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
|
|
6
7
|
import { log } from '../lib/logger.js';
|
|
8
|
+
import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
|
|
7
9
|
|
|
8
10
|
export const initCommand = new Command('init')
|
|
9
11
|
.description('Initialize DBO CLI configuration for the current directory')
|
|
@@ -16,11 +18,17 @@ export const initCommand = new Command('init')
|
|
|
16
18
|
.option('--non-interactive', 'Skip all interactive prompts')
|
|
17
19
|
.option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
|
|
18
20
|
.option('--local', 'Install Claude commands to project directory (.claude/commands/)')
|
|
21
|
+
.option('--scaffold', 'Create standard project directories (App Versions, Automations, Bins, …)')
|
|
19
22
|
.action(async (options) => {
|
|
20
23
|
// Merge --yes into nonInteractive
|
|
21
24
|
if (options.yes) options.nonInteractive = true;
|
|
22
25
|
try {
|
|
23
26
|
if (await isInitialized() && !options.force) {
|
|
27
|
+
if (options.scaffold) {
|
|
28
|
+
const result = await scaffoldProjectDirs();
|
|
29
|
+
logScaffoldResult(result);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
24
32
|
log.warn('Already initialized. Use --force to overwrite.');
|
|
25
33
|
return;
|
|
26
34
|
}
|
|
@@ -49,18 +57,32 @@ export const initCommand = new Command('init')
|
|
|
49
57
|
if (!domain || !username) {
|
|
50
58
|
const inquirer = (await import('inquirer')).default;
|
|
51
59
|
const answers = await inquirer.prompt([
|
|
52
|
-
{ type: 'input', name: 'domain', message: 'Domain (e.g., myapp.dbo.io):', default: domain || undefined, when: !domain
|
|
60
|
+
{ type: 'input', name: 'domain', message: 'Domain (e.g., myapp.dbo.io):', default: domain || undefined, when: !domain },
|
|
53
61
|
{ type: 'input', name: 'username', message: 'Username (email):', when: !username },
|
|
54
62
|
]);
|
|
55
63
|
domain = domain || answers.domain;
|
|
56
64
|
username = username || answers.username;
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
let domainChanged = false;
|
|
68
|
+
if (options.force) {
|
|
69
|
+
const { changed, proceed } = await checkDomainChange(domain, options);
|
|
70
|
+
if (changed && !proceed) {
|
|
71
|
+
log.info('Init aborted: domain change denied.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
domainChanged = changed;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
await initConfig(domain);
|
|
60
78
|
if (username) {
|
|
61
79
|
await saveCredentials(username);
|
|
62
80
|
}
|
|
63
81
|
|
|
82
|
+
if (options.force && domainChanged) {
|
|
83
|
+
await writeAppJsonDomain(domain);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
// Ensure sensitive files are gitignored
|
|
65
87
|
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
|
|
66
88
|
|
|
@@ -85,6 +107,48 @@ export const initCommand = new Command('init')
|
|
|
85
107
|
await saveTransactionKeyPreset('RowUID');
|
|
86
108
|
}
|
|
87
109
|
|
|
110
|
+
// Prompt for TicketSuggestionOutput
|
|
111
|
+
const DEFAULT_TICKET_SUGGESTION_OUTPUT = 'ojaie9t3o0kfvliahnuuda';
|
|
112
|
+
if (!options.nonInteractive) {
|
|
113
|
+
const inquirer = (await import('inquirer')).default;
|
|
114
|
+
const { ticketSuggestionOutput } = await inquirer.prompt([{
|
|
115
|
+
type: 'input',
|
|
116
|
+
name: 'ticketSuggestionOutput',
|
|
117
|
+
message: 'Output UID for ticket suggestions (leave blank to skip):',
|
|
118
|
+
default: DEFAULT_TICKET_SUGGESTION_OUTPUT,
|
|
119
|
+
}]);
|
|
120
|
+
const tsVal = ticketSuggestionOutput.trim();
|
|
121
|
+
if (tsVal) {
|
|
122
|
+
await saveTicketSuggestionOutput(tsVal);
|
|
123
|
+
log.dim(` TicketSuggestionOutput: ${tsVal}`);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
await saveTicketSuggestionOutput(DEFAULT_TICKET_SUGGESTION_OUTPUT);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Scaffold ─────────────────────────────────────────────────────────────
|
|
130
|
+
let shouldScaffold = options.scaffold;
|
|
131
|
+
|
|
132
|
+
if (!shouldScaffold && !options.nonInteractive) {
|
|
133
|
+
const entries = await readdir(process.cwd());
|
|
134
|
+
const IGNORED = new Set(['.dbo', '.claude', '.idea', '.vscode']);
|
|
135
|
+
const isEmpty = entries.every(e => IGNORED.has(e));
|
|
136
|
+
|
|
137
|
+
const inquirer = (await import('inquirer')).default;
|
|
138
|
+
const { doScaffold } = await inquirer.prompt([{
|
|
139
|
+
type: 'confirm',
|
|
140
|
+
name: 'doScaffold',
|
|
141
|
+
message: 'Would you like to scaffold the standard project directory structure?',
|
|
142
|
+
default: isEmpty,
|
|
143
|
+
}]);
|
|
144
|
+
shouldScaffold = doScaffold;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (shouldScaffold) {
|
|
148
|
+
const result = await scaffoldProjectDirs();
|
|
149
|
+
logScaffoldResult(result);
|
|
150
|
+
}
|
|
151
|
+
|
|
88
152
|
// Clone if requested
|
|
89
153
|
if (options.clone || options.app) {
|
|
90
154
|
let appShortName = options.app;
|
package/src/commands/login.js
CHANGED
|
@@ -50,22 +50,17 @@ export const loginCommand = new Command('login')
|
|
|
50
50
|
// Ensure sensitive files are gitignored
|
|
51
51
|
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
|
|
52
52
|
|
|
53
|
-
// Fetch current user info to store ID
|
|
53
|
+
// Fetch current user info to store ID for future submissions
|
|
54
54
|
try {
|
|
55
55
|
const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_format': 'json_raw' });
|
|
56
56
|
const userData = userResult.payload || userResult.data;
|
|
57
57
|
const rows = Array.isArray(userData) ? userData : (userData?.Rows || userData?.rows || []);
|
|
58
58
|
if (rows.length > 0) {
|
|
59
59
|
const row = rows[0];
|
|
60
|
-
const userUid = row.UID || row.uid;
|
|
61
60
|
const userId = row.ID || row.id || row.UserID || row.userId;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (info.userUid || info.userId) {
|
|
66
|
-
await saveUserInfo(info);
|
|
67
|
-
if (info.userId) log.dim(` User ID: ${info.userId}`);
|
|
68
|
-
if (info.userUid) log.dim(` User UID: ${info.userUid}`);
|
|
61
|
+
if (userId) {
|
|
62
|
+
await saveUserInfo({ userId: String(userId) });
|
|
63
|
+
log.dim(` User ID: ${userId}`);
|
|
69
64
|
}
|
|
70
65
|
|
|
71
66
|
// Save user profile (name, email) for package.json population
|
package/src/commands/output.js
CHANGED
|
@@ -25,6 +25,22 @@ export const outputCommand = new Command('output')
|
|
|
25
25
|
.option('--template <value>', 'Custom template')
|
|
26
26
|
.option('--debug', 'Include debug info')
|
|
27
27
|
.option('--debug-sql', 'Include SQL debug info')
|
|
28
|
+
.option('--debug-verbose', 'Verbose debug output')
|
|
29
|
+
.option('--debug-analysis', 'Analysis debug output')
|
|
30
|
+
.option('--limit <n>', 'Maximum rows to return (_limit)')
|
|
31
|
+
.option('--rowcount <bool>', 'Include row count: true (default) or false for performance')
|
|
32
|
+
.option('--display <expr>', 'Show/hide template tags (repeatable), e.g., "sidebar=hide"', collect, [])
|
|
33
|
+
.option('--format-values', 'Enable value formatting in json_raw output')
|
|
34
|
+
.option('--empty-response-code <code>', 'HTTP status code when output returns no results')
|
|
35
|
+
.option('--fallback-content <expr>', 'Fallback content UID for error codes, e.g., "404=contentUID"')
|
|
36
|
+
.option('--escape-html <bool>', 'Control HTML escaping: true or false')
|
|
37
|
+
.option('--mime <type>', 'Override MIME/content type')
|
|
38
|
+
.option('--strict', 'Strict error mode')
|
|
39
|
+
.option('--confirm', 'Confirmation flag')
|
|
40
|
+
.option('--include <expr>', 'Include token')
|
|
41
|
+
.option('--no-transaction', 'Disable transaction wrapping')
|
|
42
|
+
.option('--skip <phase>', 'Skip execution phases (repeatable, admin-only)', collect, [])
|
|
43
|
+
.option('--profile', 'Enable MiniProfiler output')
|
|
28
44
|
.option('--meta', 'Use meta output endpoint')
|
|
29
45
|
.option('--meta-column <uid>', 'Column metadata')
|
|
30
46
|
.option('--save', 'Interactive save-to-disk mode')
|
|
@@ -66,8 +82,45 @@ export const outputCommand = new Command('output')
|
|
|
66
82
|
if (options.maxrows) params['_maxrows'] = options.maxrows;
|
|
67
83
|
if (options.rows) params['_rows'] = options.rows;
|
|
68
84
|
if (options.template) params['_template'] = options.template;
|
|
85
|
+
if (options.limit) params['_limit'] = options.limit;
|
|
86
|
+
if (options.rowcount !== undefined) params['_rowcount'] = options.rowcount;
|
|
69
87
|
if (options.debug) params['_debug'] = 'true';
|
|
70
88
|
if (options.debugSql) params['_debug_sql'] = 'true';
|
|
89
|
+
if (options.debugVerbose) params['_debug:verbose'] = 'true';
|
|
90
|
+
if (options.debugAnalysis) params['_debug:analysis'] = 'true';
|
|
91
|
+
if (options.formatValues) params['_format_values'] = 'true';
|
|
92
|
+
if (options.emptyResponseCode) params['_empty_response_code'] = options.emptyResponseCode;
|
|
93
|
+
if (options.escapeHtml !== undefined) params['_escape_html'] = options.escapeHtml;
|
|
94
|
+
if (options.mime) params['_mime'] = options.mime;
|
|
95
|
+
if (options.strict) params['_strict'] = 'true';
|
|
96
|
+
if (options.confirm) params['_confirm'] = 'true';
|
|
97
|
+
if (options.include) params['_include'] = options.include;
|
|
98
|
+
if (options.transaction === false) params['_no_transaction'] = 'true';
|
|
99
|
+
if (options.profile) params['_profile'] = 'true';
|
|
100
|
+
|
|
101
|
+
// --display: "tagName=show|hide" → _display@tagName=show|hide
|
|
102
|
+
for (const d of options.display) {
|
|
103
|
+
const eqIdx = d.indexOf('=');
|
|
104
|
+
if (eqIdx !== -1) {
|
|
105
|
+
params[`_display@${d.substring(0, eqIdx)}`] = d.substring(eqIdx + 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --fallback-content: "404=contentUID" → _fallback_content:404=contentUID
|
|
110
|
+
if (options.fallbackContent) {
|
|
111
|
+
const fb = options.fallbackContent;
|
|
112
|
+
if (fb.includes('=')) {
|
|
113
|
+
const [code, uid] = fb.split('=', 2);
|
|
114
|
+
params[`_fallback_content:${code}`] = uid;
|
|
115
|
+
} else {
|
|
116
|
+
params['_fallback_content'] = fb;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --skip: repeatable → array
|
|
121
|
+
if (options.skip.length > 0) {
|
|
122
|
+
params['_skip'] = options.skip;
|
|
123
|
+
}
|
|
71
124
|
|
|
72
125
|
for (const f of options.filter) {
|
|
73
126
|
const atIdx = f.indexOf('=');
|
|
@@ -84,9 +137,9 @@ export const outputCommand = new Command('output')
|
|
|
84
137
|
}
|
|
85
138
|
}
|
|
86
139
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
params[
|
|
140
|
+
// Pass sort values through directly — API expects _sort=Column:DIR
|
|
141
|
+
if (options.sort.length > 0) {
|
|
142
|
+
params['_sort'] = options.sort;
|
|
90
143
|
}
|
|
91
144
|
|
|
92
145
|
// For save mode or jq mode, force json_raw if user didn't set a custom format
|
package/src/commands/pull.js
CHANGED
|
@@ -40,9 +40,9 @@ export const pullCommand = new Command('pull')
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
params[
|
|
43
|
+
// Pass sort values through directly — API expects _sort=Column:DIR
|
|
44
|
+
if (options.sort.length > 0) {
|
|
45
|
+
params['_sort'] = options.sort;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
let path;
|
package/src/commands/push.js
CHANGED
|
@@ -13,7 +13,7 @@ import { resolveTransactionKey } from '../lib/transaction-key.js';
|
|
|
13
13
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
14
14
|
import { findMetadataFiles } from '../lib/diff.js';
|
|
15
15
|
import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
|
|
16
|
-
import {
|
|
16
|
+
import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
|
|
17
17
|
|
|
18
18
|
export const pushCommand = new Command('push')
|
|
19
19
|
.description('Push local files back to DBO.io using metadata from pull')
|
|
@@ -262,13 +262,12 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
265
|
+
// Sort by dependency level: children first (ascending level) for add/edit operations
|
|
266
|
+
toPush.sort((a, b) => {
|
|
267
|
+
const levelA = ENTITY_DEPENDENCIES[a.meta._entity] || 0;
|
|
268
|
+
const levelB = ENTITY_DEPENDENCIES[b.meta._entity] || 0;
|
|
269
|
+
return levelA - levelB;
|
|
270
|
+
});
|
|
272
271
|
|
|
273
272
|
// Process in dependency order
|
|
274
273
|
let succeeded = 0;
|
|
@@ -361,6 +360,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
361
360
|
for (const [key, value] of Object.entries(meta)) {
|
|
362
361
|
if (shouldSkipColumn(key)) continue;
|
|
363
362
|
if (key === 'UID') continue; // UID is the identifier, not a column to update
|
|
363
|
+
if (key === 'children') continue; // Output hierarchy structural field, not a server column
|
|
364
364
|
if (value === null || value === undefined) continue;
|
|
365
365
|
|
|
366
366
|
// Delta sync: skip columns not in changedColumns
|
|
@@ -413,7 +413,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
413
413
|
}
|
|
414
414
|
|
|
415
415
|
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
416
|
-
const retryResult = await checkSubmitErrors(result);
|
|
416
|
+
const retryResult = await checkSubmitErrors(result, { rowUid: uid });
|
|
417
417
|
if (retryResult) {
|
|
418
418
|
// Handle skip actions
|
|
419
419
|
if (retryResult.skipRecord) {
|
package/src/commands/status.js
CHANGED
|
@@ -18,7 +18,6 @@ export const statusCommand = new Command('status')
|
|
|
18
18
|
log.label('Username', config.username || '(not set)');
|
|
19
19
|
const userInfo = await loadUserInfo();
|
|
20
20
|
log.label('User ID', userInfo.userId || '(not set)');
|
|
21
|
-
log.label('User UID', userInfo.userUid || '(not set — run "dbo login")');
|
|
22
21
|
log.label('Directory', process.cwd());
|
|
23
22
|
|
|
24
23
|
const cookiesPath = await getActiveCookiesPath();
|
package/src/lib/config.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function initConfig(domain) {
|
|
|
69
69
|
|
|
70
70
|
export async function saveCredentials(username) {
|
|
71
71
|
await mkdir(dboDir(), { recursive: true });
|
|
72
|
-
// Preserve existing fields (like
|
|
72
|
+
// Preserve existing fields (like userId) — never store password
|
|
73
73
|
let existing = {};
|
|
74
74
|
try {
|
|
75
75
|
existing = JSON.parse(await readFile(credentialsPath(), 'utf8'));
|
|
@@ -79,14 +79,13 @@ export async function saveCredentials(username) {
|
|
|
79
79
|
await writeFile(credentialsPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
export async function saveUserInfo({ userId
|
|
82
|
+
export async function saveUserInfo({ userId }) {
|
|
83
83
|
await mkdir(dboDir(), { recursive: true });
|
|
84
84
|
let existing = {};
|
|
85
85
|
try {
|
|
86
86
|
existing = JSON.parse(await readFile(credentialsPath(), 'utf8'));
|
|
87
87
|
} catch { /* no existing file */ }
|
|
88
88
|
if (userId !== undefined) existing.userId = userId;
|
|
89
|
-
if (userUid !== undefined) existing.userUid = userUid;
|
|
90
89
|
await writeFile(credentialsPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
91
90
|
}
|
|
92
91
|
|
|
@@ -94,12 +93,9 @@ export async function loadUserInfo() {
|
|
|
94
93
|
try {
|
|
95
94
|
const raw = await readFile(credentialsPath(), 'utf8');
|
|
96
95
|
const creds = JSON.parse(raw);
|
|
97
|
-
return {
|
|
98
|
-
userId: creds.userId || null,
|
|
99
|
-
userUid: creds.userUid || null,
|
|
100
|
-
};
|
|
96
|
+
return { userId: creds.userId || null };
|
|
101
97
|
} catch {
|
|
102
|
-
return { userId: null
|
|
98
|
+
return { userId: null };
|
|
103
99
|
}
|
|
104
100
|
}
|
|
105
101
|
|
|
@@ -529,6 +525,50 @@ export async function getAllPluginScopes() {
|
|
|
529
525
|
return result;
|
|
530
526
|
}
|
|
531
527
|
|
|
528
|
+
// ─── Output Hierarchy Filename Preferences ────────────────────────────────
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Config keys for output hierarchy filename column preferences.
|
|
532
|
+
* Maps physical table names to config key names.
|
|
533
|
+
*/
|
|
534
|
+
const OUTPUT_FILENAME_CONFIG_KEYS = {
|
|
535
|
+
output: 'OutputFilenameCol',
|
|
536
|
+
output_value: 'OutputColumnFilenameCol',
|
|
537
|
+
output_value_filter: 'OutputFilterFilenameCol',
|
|
538
|
+
output_value_entity_column_rel: 'OutputJoinFilenameCol',
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Load output filename column preference for an entity type.
|
|
543
|
+
* @param {string} entityKey - Physical table name (e.g., 'output', 'output_value')
|
|
544
|
+
* @returns {Promise<string|null>}
|
|
545
|
+
*/
|
|
546
|
+
export async function loadOutputFilenamePreference(entityKey) {
|
|
547
|
+
try {
|
|
548
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
549
|
+
const config = JSON.parse(raw);
|
|
550
|
+
const configKey = OUTPUT_FILENAME_CONFIG_KEYS[entityKey];
|
|
551
|
+
return configKey ? (config[configKey] || null) : null;
|
|
552
|
+
} catch {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Save output filename column preference for an entity type.
|
|
559
|
+
* @param {string} entityKey - Physical table name
|
|
560
|
+
* @param {string} columnName - Column name to use as filename
|
|
561
|
+
*/
|
|
562
|
+
export async function saveOutputFilenamePreference(entityKey, columnName) {
|
|
563
|
+
const configKey = OUTPUT_FILENAME_CONFIG_KEYS[entityKey];
|
|
564
|
+
if (!configKey) return;
|
|
565
|
+
await mkdir(dboDir(), { recursive: true });
|
|
566
|
+
let existing = {};
|
|
567
|
+
try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
|
|
568
|
+
existing[configKey] = columnName;
|
|
569
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
570
|
+
}
|
|
571
|
+
|
|
532
572
|
// ─── AppModifyKey ─────────────────────────────────────────────────────────
|
|
533
573
|
|
|
534
574
|
/**
|
|
@@ -580,6 +620,32 @@ export async function loadTransactionKeyPreset() {
|
|
|
580
620
|
} catch { return null; }
|
|
581
621
|
}
|
|
582
622
|
|
|
623
|
+
// ─── TicketSuggestionOutput ────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Save TicketSuggestionOutput to .dbo/config.json.
|
|
627
|
+
* This is the output UID used to fetch ticket suggestions.
|
|
628
|
+
*/
|
|
629
|
+
export async function saveTicketSuggestionOutput(outputUid) {
|
|
630
|
+
await mkdir(dboDir(), { recursive: true });
|
|
631
|
+
let existing = {};
|
|
632
|
+
try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
|
|
633
|
+
if (outputUid != null) existing.TicketSuggestionOutput = outputUid;
|
|
634
|
+
else delete existing.TicketSuggestionOutput;
|
|
635
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Load TicketSuggestionOutput from .dbo/config.json.
|
|
640
|
+
* Returns the output UID string or null if not set.
|
|
641
|
+
*/
|
|
642
|
+
export async function loadTicketSuggestionOutput() {
|
|
643
|
+
try {
|
|
644
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
645
|
+
return JSON.parse(raw).TicketSuggestionOutput || null;
|
|
646
|
+
} catch { return null; }
|
|
647
|
+
}
|
|
648
|
+
|
|
583
649
|
// ─── Gitignore ────────────────────────────────────────────────────────────
|
|
584
650
|
|
|
585
651
|
/**
|
package/src/lib/diff.js
CHANGED
|
@@ -28,7 +28,8 @@ async function fileExists(path) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Recursively find all
|
|
31
|
+
* Recursively find all metadata files in a directory.
|
|
32
|
+
* Includes .metadata.json files and output hierarchy files (_output~*.json).
|
|
32
33
|
*/
|
|
33
34
|
export async function findMetadataFiles(dir) {
|
|
34
35
|
const results = [];
|
|
@@ -42,6 +43,9 @@ export async function findMetadataFiles(dir) {
|
|
|
42
43
|
results.push(...await findMetadataFiles(fullPath));
|
|
43
44
|
} else if (entry.name.endsWith('.metadata.json')) {
|
|
44
45
|
results.push(fullPath);
|
|
46
|
+
} else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
|
|
47
|
+
// Output hierarchy files: _output~<name>~<uid>.json and nested entity files
|
|
48
|
+
results.push(fullPath);
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFile, writeFile, stat, utimes } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { loadConfig, loadTransactionKeyPreset } from './config.js';
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
|
|
6
|
+
const appJsonPath = () => join(process.cwd(), 'app.json');
|
|
7
|
+
|
|
8
|
+
/** Read _domain from app.json. Returns null if file missing or field absent. */
|
|
9
|
+
export async function readAppJsonDomain() {
|
|
10
|
+
try {
|
|
11
|
+
const obj = JSON.parse(await readFile(appJsonPath(), 'utf8'));
|
|
12
|
+
return obj._domain || null;
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Write _domain to app.json, preserving mtime so git doesn't see a spurious change.
|
|
18
|
+
* No-op if app.json does not exist.
|
|
19
|
+
*/
|
|
20
|
+
export async function writeAppJsonDomain(domain) {
|
|
21
|
+
const path = appJsonPath();
|
|
22
|
+
let originalStat;
|
|
23
|
+
try { originalStat = await stat(path); } catch { return; }
|
|
24
|
+
const obj = JSON.parse(await readFile(path, 'utf8'));
|
|
25
|
+
obj._domain = domain;
|
|
26
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n');
|
|
27
|
+
await utimes(path, originalStat.atime, originalStat.mtime); // restore mtime
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check whether newDomain differs from the project reference domain.
|
|
32
|
+
* Reference = app.json._domain (authoritative) → fallback: config.json.domain.
|
|
33
|
+
* If reference is absent entirely, returns { changed: false } (no warning).
|
|
34
|
+
*
|
|
35
|
+
* Returns { changed: false } or { changed: true, proceed: bool }.
|
|
36
|
+
* Throws in non-interactive mode when TransactionKeyPreset=RowID and domain changes.
|
|
37
|
+
*/
|
|
38
|
+
export async function checkDomainChange(newDomain, options = {}) {
|
|
39
|
+
let referenceDomain = await readAppJsonDomain();
|
|
40
|
+
if (!referenceDomain) {
|
|
41
|
+
const config = await loadConfig();
|
|
42
|
+
referenceDomain = config.domain || null;
|
|
43
|
+
}
|
|
44
|
+
if (!referenceDomain) return { changed: false };
|
|
45
|
+
|
|
46
|
+
const norm = d => d ? String(d).replace(/^https?:\/\//, '').replace(/\/$/, '').toLowerCase() : null;
|
|
47
|
+
if (norm(newDomain) === norm(referenceDomain)) return { changed: false };
|
|
48
|
+
|
|
49
|
+
const preset = await loadTransactionKeyPreset();
|
|
50
|
+
const isRowID = preset === 'RowID';
|
|
51
|
+
|
|
52
|
+
if (options.yes || !process.stdin.isTTY) {
|
|
53
|
+
if (isRowID) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Domain change detected: "${referenceDomain}" → "${newDomain}"\n` +
|
|
56
|
+
`Cannot proceed in non-interactive mode with TransactionKeyPreset=RowID. ` +
|
|
57
|
+
`Numeric IDs are not unique across domains — submitting to the wrong domain may overwrite or corrupt records.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
log.warn(`Domain change detected: "${referenceDomain}" → "${newDomain}". Proceeding (TransactionKeyPreset=RowUID).`);
|
|
61
|
+
return { changed: true, proceed: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const inquirer = (await import('inquirer')).default;
|
|
65
|
+
log.plain('');
|
|
66
|
+
|
|
67
|
+
if (isRowID) {
|
|
68
|
+
log.error('⛔ CRITICAL: Domain change detected');
|
|
69
|
+
log.warn(` Reference domain : ${referenceDomain}`);
|
|
70
|
+
log.warn(` New domain : ${newDomain}`);
|
|
71
|
+
log.plain('');
|
|
72
|
+
log.warn(' TransactionKeyPreset=RowID is active. Numeric IDs are NOT unique across');
|
|
73
|
+
log.warn(' domains. Pushing to the wrong domain may overwrite or corrupt records in');
|
|
74
|
+
log.warn(' a different app.');
|
|
75
|
+
log.plain('');
|
|
76
|
+
const { confirmText } = await inquirer.prompt([{
|
|
77
|
+
type: 'input',
|
|
78
|
+
name: 'confirmText',
|
|
79
|
+
message: `Type "yes, change domain" to confirm switching to "${newDomain}":`,
|
|
80
|
+
}]);
|
|
81
|
+
return { changed: true, proceed: confirmText.trim() === 'yes, change domain' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log.warn('⚠ Domain change detected');
|
|
85
|
+
log.dim(` Reference domain : ${referenceDomain}`);
|
|
86
|
+
log.dim(` New domain : ${newDomain}`);
|
|
87
|
+
log.plain('');
|
|
88
|
+
const { confirm } = await inquirer.prompt([{
|
|
89
|
+
type: 'confirm',
|
|
90
|
+
name: 'confirm',
|
|
91
|
+
message: `Switch domain from "${referenceDomain}" to "${newDomain}"?`,
|
|
92
|
+
default: false,
|
|
93
|
+
}]);
|
|
94
|
+
return { changed: true, proceed: confirm };
|
|
95
|
+
}
|