d365fo-mcp 1.2.0 → 1.3.0
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/dist/bridge/bridgeAdapter.js +3 -0
- package/dist/cli/commands/indexCmd.js +9 -3
- package/dist/config/configFile.d.ts +17 -0
- package/dist/config/configFile.js +25 -0
- package/dist/metadata/symbolIndex.d.ts +13 -0
- package/dist/metadata/symbolIndex.js +41 -1
- package/dist/scripts/build-database.js +50 -3
- package/dist/scripts/build-fts.js +50 -3
- package/dist/scripts/extract-metadata.js +11 -0
- package/dist/server/toolSchemas/d365foFile.js +2 -0
- package/dist/server/toolSchemas/generateObject.d.ts +4 -0
- package/dist/server/toolSchemas/generateObject.js +2 -1
- package/dist/server/toolSchemas/index.d.ts +4 -0
- package/dist/tools/d365foFileOpSpecs.js +19 -0
- package/dist/tools/generateObject.d.ts +0 -12
- package/dist/tools/generateSmart.d.ts +1 -7
- package/dist/tools/generateSmart.js +5 -1
- package/dist/tools/generateSmartTable.d.ts +8 -0
- package/dist/tools/generateSmartTable.js +43 -18
- package/dist/tools/modifyD365File.d.ts +2 -0
- package/dist/tools/modifyD365File.js +91 -0
- package/dist/tools/validateXpp.js +7 -7
- package/dist/utils/axFormDesignProperties.d.ts +25 -0
- package/dist/utils/axFormDesignProperties.js +78 -0
- package/dist/utils/loadEnv.d.ts +2 -0
- package/dist/utils/loadEnv.js +11 -1
- package/package.json +1 -1
|
@@ -1009,6 +1009,9 @@ const BRIDGE_MODIFY_OPS = new Set([
|
|
|
1009
1009
|
'add-field', 'modify-field', 'rename-field', 'replace-all-fields', 'remove-field',
|
|
1010
1010
|
'add-index', 'remove-index',
|
|
1011
1011
|
'add-relation', 'remove-relation',
|
|
1012
|
+
// No C# op backs these — they are served by a direct-XML writer, but they still
|
|
1013
|
+
// pass through the same modify gate.
|
|
1014
|
+
'add-delete-action', 'remove-delete-action',
|
|
1012
1015
|
'add-field-group', 'remove-field-group', 'add-field-to-field-group',
|
|
1013
1016
|
'modify-property',
|
|
1014
1017
|
'add-enum-value', 'modify-enum-value', 'remove-enum-value',
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* instances/rebuild-instance.ps1 minus the git-pull step (that lives in
|
|
5
5
|
* `d365fo-mcp update`).
|
|
6
6
|
*/
|
|
7
|
-
import { installMode, isWindows, paths } from '../context.js';
|
|
7
|
+
import { dataRoot, installMode, isWindows, paths } from '../context.js';
|
|
8
8
|
import { runNode } from '../exec.js';
|
|
9
9
|
import { listInstances } from '../instances.js';
|
|
10
10
|
import { instanceTarget, pickTarget, rootTarget, targetEnv } from '../target.js';
|
|
@@ -25,18 +25,24 @@ function scriptArgs(tsSource, bundle) {
|
|
|
25
25
|
/** Run extract + build-database for one target. Returns true on success. */
|
|
26
26
|
export async function rebuildIndex(target) {
|
|
27
27
|
const env = targetEnv(target);
|
|
28
|
+
// Run from the target's own directory, not the package: the index scripts
|
|
29
|
+
// fall back to relative literals ('./data/xpp-metadata.db') whenever a
|
|
30
|
+
// setting is absent AND no config file exists to anchor it, and the default
|
|
31
|
+
// cwd of runNode is repoRoot — which for an npm install is the package npm
|
|
32
|
+
// replaces on every update, on whatever drive npm happens to live on.
|
|
33
|
+
const cwd = target.instance?.dir ?? dataRoot();
|
|
28
34
|
if (isWindows) {
|
|
29
35
|
const expanded = normalizeXppConfigName(target.store);
|
|
30
36
|
if (expanded)
|
|
31
37
|
p.log.info(`Expanded XPP config name: ${expanded.from} → ${expanded.to}`);
|
|
32
38
|
}
|
|
33
39
|
p.log.step(`[1/2] Extracting metadata (${target.label})…`);
|
|
34
|
-
if (await runNode(scriptArgs(paths.extractScript, paths.extractScriptDist), { env }) !== 0) {
|
|
40
|
+
if (await runNode(scriptArgs(paths.extractScript, paths.extractScriptDist), { cwd, env }) !== 0) {
|
|
35
41
|
p.log.error(`Metadata extraction failed for ${target.label}`);
|
|
36
42
|
return false;
|
|
37
43
|
}
|
|
38
44
|
p.log.step(`[2/2] Building database (${target.label})…`);
|
|
39
|
-
if (await runNode(['--max-old-space-size=6144', ...scriptArgs(paths.buildDbScript, paths.buildDbScriptDist)], { env }) !== 0) {
|
|
45
|
+
if (await runNode(['--max-old-space-size=6144', ...scriptArgs(paths.buildDbScript, paths.buildDbScriptDist)], { cwd, env }) !== 0) {
|
|
40
46
|
p.log.error(`Database build failed for ${target.label}`);
|
|
41
47
|
return false;
|
|
42
48
|
}
|
|
@@ -41,6 +41,23 @@ export declare function configBaseDir(configPath: string): string;
|
|
|
41
41
|
* can use portable values like ./data/xpp-metadata.db.
|
|
42
42
|
*/
|
|
43
43
|
export declare function toEnvRecord(files: Pick<ResolvedConfigFiles, 'baseDir' | 'config' | 'secrets'>): Record<string, string>;
|
|
44
|
+
/**
|
|
45
|
+
* The path settings the wizard never writes, resolved against `baseDir`.
|
|
46
|
+
*
|
|
47
|
+
* DB_PATH, LABELS_DB_PATH and METADATA_PATH are advanced settings with
|
|
48
|
+
* relative defaults, so a normal setup leaves them out of the config file
|
|
49
|
+
* entirely and every consumer falls back to its own `'./data/…'` literal —
|
|
50
|
+
* which resolves from process.cwd(). For a git checkout that is the repo, and
|
|
51
|
+
* the answer happens to be right. For an npm install it is the *package*
|
|
52
|
+
* directory: `d365fo-mcp index` spawns the build scripts with cwd = repoRoot,
|
|
53
|
+
* so a 2 GB index landed next to the installed package, on whatever drive npm
|
|
54
|
+
* lives on, instead of in the installation directory the user chose in setup
|
|
55
|
+
* (issue: build ran out of space on C: and SQLite aborted the transaction).
|
|
56
|
+
*
|
|
57
|
+
* Emitting the defaults here pins them to the installation directory instead.
|
|
58
|
+
* A checkout is its own data directory, so its paths do not move.
|
|
59
|
+
*/
|
|
60
|
+
export declare function defaultPathEnv(baseDir: string): Record<string, string>;
|
|
44
61
|
/** Write the config file, creating its directory. Keys are emitted in registry order. */
|
|
45
62
|
export declare function writeConfigFile(configPath: string, config: ConfigObject): void;
|
|
46
63
|
/** Write secrets.json with owner-only permissions where the platform supports them. */
|
|
@@ -111,6 +111,31 @@ export function toEnvRecord(files) {
|
|
|
111
111
|
}
|
|
112
112
|
return out;
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* The path settings the wizard never writes, resolved against `baseDir`.
|
|
116
|
+
*
|
|
117
|
+
* DB_PATH, LABELS_DB_PATH and METADATA_PATH are advanced settings with
|
|
118
|
+
* relative defaults, so a normal setup leaves them out of the config file
|
|
119
|
+
* entirely and every consumer falls back to its own `'./data/…'` literal —
|
|
120
|
+
* which resolves from process.cwd(). For a git checkout that is the repo, and
|
|
121
|
+
* the answer happens to be right. For an npm install it is the *package*
|
|
122
|
+
* directory: `d365fo-mcp index` spawns the build scripts with cwd = repoRoot,
|
|
123
|
+
* so a 2 GB index landed next to the installed package, on whatever drive npm
|
|
124
|
+
* lives on, instead of in the installation directory the user chose in setup
|
|
125
|
+
* (issue: build ran out of space on C: and SQLite aborted the transaction).
|
|
126
|
+
*
|
|
127
|
+
* Emitting the defaults here pins them to the installation directory instead.
|
|
128
|
+
* A checkout is its own data directory, so its paths do not move.
|
|
129
|
+
*/
|
|
130
|
+
export function defaultPathEnv(baseDir) {
|
|
131
|
+
const out = {};
|
|
132
|
+
for (const setting of SETTINGS) {
|
|
133
|
+
if (setting.type !== 'path' || typeof setting.default !== 'string' || setting.default === '')
|
|
134
|
+
continue;
|
|
135
|
+
out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
114
139
|
/** Write the config file, creating its directory. Keys are emitted in registry order. */
|
|
115
140
|
export function writeConfigFile(configPath, config) {
|
|
116
141
|
fs.mkdirSync(dirname(configPath), { recursive: true });
|
|
@@ -212,6 +212,19 @@ export declare class XppSymbolIndex {
|
|
|
212
212
|
* database (a custom-model build: ~10K of ~1.2M symbols, where the full
|
|
213
213
|
* rebuild cost 327s against 5s of actual indexing work).
|
|
214
214
|
*/
|
|
215
|
+
/**
|
|
216
|
+
* Turn a write failure inside a model transaction into an error that names
|
|
217
|
+
* the database being written and, when the drive is the likely cause, how
|
|
218
|
+
* much room is left on it.
|
|
219
|
+
*
|
|
220
|
+
* A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
|
|
221
|
+
* wrapper then fails to COMMIT and the only thing the user sees is
|
|
222
|
+
* "cannot commit - no transaction is active" with a stack inside the library —
|
|
223
|
+
* no path, no mention of space. That message sent at least one user hunting
|
|
224
|
+
* for a corrupt index when the index was simply being written to the wrong
|
|
225
|
+
* (and nearly full) drive.
|
|
226
|
+
*/
|
|
227
|
+
private describeWriteFailure;
|
|
215
228
|
indexMetadataDirectory(metadataPath: string, modelNames?: string | string[], opts?: {
|
|
216
229
|
ftsStrategy?: 'rebuild' | 'incremental';
|
|
217
230
|
}): Promise<void>;
|
|
@@ -1172,6 +1172,41 @@ export class XppSymbolIndex {
|
|
|
1172
1172
|
* database (a custom-model build: ~10K of ~1.2M symbols, where the full
|
|
1173
1173
|
* rebuild cost 327s against 5s of actual indexing work).
|
|
1174
1174
|
*/
|
|
1175
|
+
/**
|
|
1176
|
+
* Turn a write failure inside a model transaction into an error that names
|
|
1177
|
+
* the database being written and, when the drive is the likely cause, how
|
|
1178
|
+
* much room is left on it.
|
|
1179
|
+
*
|
|
1180
|
+
* A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
|
|
1181
|
+
* wrapper then fails to COMMIT and the only thing the user sees is
|
|
1182
|
+
* "cannot commit - no transaction is active" with a stack inside the library —
|
|
1183
|
+
* no path, no mention of space. That message sent at least one user hunting
|
|
1184
|
+
* for a corrupt index when the index was simply being written to the wrong
|
|
1185
|
+
* (and nearly full) drive.
|
|
1186
|
+
*/
|
|
1187
|
+
describeWriteFailure(err, model) {
|
|
1188
|
+
const original = err instanceof Error ? err : new Error(String(err));
|
|
1189
|
+
const message = original.message;
|
|
1190
|
+
const diskRelated = /disk is full|SQLITE_FULL|disk I\/O error|no transaction is active/i.test(message);
|
|
1191
|
+
if (!diskRelated)
|
|
1192
|
+
return original;
|
|
1193
|
+
let space = '';
|
|
1194
|
+
try {
|
|
1195
|
+
const stat = fs.statfsSync(path.dirname(path.resolve(this.dbPath)));
|
|
1196
|
+
const freeGb = (Number(stat.bavail) * Number(stat.bsize)) / 1024 ** 3;
|
|
1197
|
+
space = ` (${freeGb.toFixed(1)} GB free there)`;
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
// statfs is best-effort — the path advice below is the useful part.
|
|
1201
|
+
}
|
|
1202
|
+
const wrapped = new Error(`Writing model '${model}' to ${this.dbPath} failed: ${message}\n` +
|
|
1203
|
+
`The index is written to that path${space}. A full disk makes SQLite roll the write back on its own, ` +
|
|
1204
|
+
`which is what surfaces as "cannot commit - no transaction is active".\n` +
|
|
1205
|
+
`Point the installation at a drive with room (a full index needs several GB): ` +
|
|
1206
|
+
`re-run 'd365fo-mcp setup' and choose another directory, or set index.dbPath / index.metadataPath in d365fo-mcp.json.`);
|
|
1207
|
+
wrapped.cause = original;
|
|
1208
|
+
return wrapped;
|
|
1209
|
+
}
|
|
1175
1210
|
async indexMetadataDirectory(metadataPath, modelNames, opts) {
|
|
1176
1211
|
const skipFts = process.env.SKIP_FTS === 'true';
|
|
1177
1212
|
const resumable = process.env.RESUME === 'true';
|
|
@@ -1357,7 +1392,12 @@ export class XppSymbolIndex {
|
|
|
1357
1392
|
// Mark model as done atomically with its data (same transaction)
|
|
1358
1393
|
markProgress?.run(model, Date.now());
|
|
1359
1394
|
});
|
|
1360
|
-
|
|
1395
|
+
try {
|
|
1396
|
+
tx();
|
|
1397
|
+
}
|
|
1398
|
+
catch (err) {
|
|
1399
|
+
throw this.describeWriteFailure(err, model);
|
|
1400
|
+
}
|
|
1361
1401
|
const modelDuration = ((Date.now() - modelStartTime) / 1000).toFixed(1);
|
|
1362
1402
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
|
1363
1403
|
if (isCI()) {
|
|
@@ -595,6 +595,14 @@ function toEnvRecord(files) {
|
|
|
595
595
|
}
|
|
596
596
|
return out;
|
|
597
597
|
}
|
|
598
|
+
function defaultPathEnv(baseDir) {
|
|
599
|
+
const out = {};
|
|
600
|
+
for (const setting of SETTINGS) {
|
|
601
|
+
if (setting.type !== "path" || typeof setting.default !== "string" || setting.default === "") continue;
|
|
602
|
+
out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
598
606
|
|
|
599
607
|
// src/utils/loadEnv.ts
|
|
600
608
|
var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
|
|
@@ -629,6 +637,9 @@ function loadEnv(callerImportMetaUrl) {
|
|
|
629
637
|
for (const [key, value] of Object.entries(toEnvRecord(files))) {
|
|
630
638
|
if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key)) process.env[key] = value;
|
|
631
639
|
}
|
|
640
|
+
for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
|
|
641
|
+
if (!process.env[key]) process.env[key] = value;
|
|
642
|
+
}
|
|
632
643
|
if (process.env.D365FO_DEV_ENVIRONMENT_TYPE) {
|
|
633
644
|
process.env.DEV_ENVIRONMENT_TYPE = process.env.D365FO_DEV_ENVIRONMENT_TYPE;
|
|
634
645
|
}
|
|
@@ -1764,13 +1775,13 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1764
1775
|
if (this.dbPath === ":memory:") {
|
|
1765
1776
|
return Promise.reject(new Error("in-memory DB is not visible to worker threads"));
|
|
1766
1777
|
}
|
|
1767
|
-
return new Promise((
|
|
1778
|
+
return new Promise((resolve4, reject) => {
|
|
1768
1779
|
const worker = new Worker(new URL("./symbolCountsWorker.js", import.meta.url), {
|
|
1769
1780
|
workerData: { dbPath: this.dbPath }
|
|
1770
1781
|
});
|
|
1771
1782
|
worker.once("message", (msg) => {
|
|
1772
1783
|
if (msg.ok) {
|
|
1773
|
-
|
|
1784
|
+
resolve4({ total: msg.total, byType: msg.byType });
|
|
1774
1785
|
} else {
|
|
1775
1786
|
reject(new Error(msg.error));
|
|
1776
1787
|
}
|
|
@@ -1928,6 +1939,38 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1928
1939
|
* database (a custom-model build: ~10K of ~1.2M symbols, where the full
|
|
1929
1940
|
* rebuild cost 327s against 5s of actual indexing work).
|
|
1930
1941
|
*/
|
|
1942
|
+
/**
|
|
1943
|
+
* Turn a write failure inside a model transaction into an error that names
|
|
1944
|
+
* the database being written and, when the drive is the likely cause, how
|
|
1945
|
+
* much room is left on it.
|
|
1946
|
+
*
|
|
1947
|
+
* A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
|
|
1948
|
+
* wrapper then fails to COMMIT and the only thing the user sees is
|
|
1949
|
+
* "cannot commit - no transaction is active" with a stack inside the library —
|
|
1950
|
+
* no path, no mention of space. That message sent at least one user hunting
|
|
1951
|
+
* for a corrupt index when the index was simply being written to the wrong
|
|
1952
|
+
* (and nearly full) drive.
|
|
1953
|
+
*/
|
|
1954
|
+
describeWriteFailure(err, model) {
|
|
1955
|
+
const original = err instanceof Error ? err : new Error(String(err));
|
|
1956
|
+
const message = original.message;
|
|
1957
|
+
const diskRelated = /disk is full|SQLITE_FULL|disk I\/O error|no transaction is active/i.test(message);
|
|
1958
|
+
if (!diskRelated) return original;
|
|
1959
|
+
let space = "";
|
|
1960
|
+
try {
|
|
1961
|
+
const stat2 = fs2.statfsSync(path.dirname(path.resolve(this.dbPath)));
|
|
1962
|
+
const freeGb = Number(stat2.bavail) * Number(stat2.bsize) / 1024 ** 3;
|
|
1963
|
+
space = ` (${freeGb.toFixed(1)} GB free there)`;
|
|
1964
|
+
} catch {
|
|
1965
|
+
}
|
|
1966
|
+
const wrapped = new Error(
|
|
1967
|
+
`Writing model '${model}' to ${this.dbPath} failed: ${message}
|
|
1968
|
+
The index is written to that path${space}. A full disk makes SQLite roll the write back on its own, which is what surfaces as "cannot commit - no transaction is active".
|
|
1969
|
+
Point the installation at a drive with room (a full index needs several GB): re-run 'd365fo-mcp setup' and choose another directory, or set index.dbPath / index.metadataPath in d365fo-mcp.json.`
|
|
1970
|
+
);
|
|
1971
|
+
wrapped.cause = original;
|
|
1972
|
+
return wrapped;
|
|
1973
|
+
}
|
|
1931
1974
|
async indexMetadataDirectory(metadataPath, modelNames, opts) {
|
|
1932
1975
|
const skipFts = process.env.SKIP_FTS === "true";
|
|
1933
1976
|
const resumable = process.env.RESUME === "true";
|
|
@@ -2045,7 +2088,11 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2045
2088
|
this.flushPropertyStats();
|
|
2046
2089
|
markProgress?.run(model, Date.now());
|
|
2047
2090
|
});
|
|
2048
|
-
|
|
2091
|
+
try {
|
|
2092
|
+
tx();
|
|
2093
|
+
} catch (err) {
|
|
2094
|
+
throw this.describeWriteFailure(err, model);
|
|
2095
|
+
}
|
|
2049
2096
|
const modelDuration = ((Date.now() - modelStartTime) / 1e3).toFixed(1);
|
|
2050
2097
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(0);
|
|
2051
2098
|
if (isCI()) {
|
|
@@ -595,6 +595,14 @@ function toEnvRecord(files) {
|
|
|
595
595
|
}
|
|
596
596
|
return out;
|
|
597
597
|
}
|
|
598
|
+
function defaultPathEnv(baseDir) {
|
|
599
|
+
const out = {};
|
|
600
|
+
for (const setting of SETTINGS) {
|
|
601
|
+
if (setting.type !== "path" || typeof setting.default !== "string" || setting.default === "") continue;
|
|
602
|
+
out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
598
606
|
|
|
599
607
|
// src/utils/loadEnv.ts
|
|
600
608
|
var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
|
|
@@ -629,6 +637,9 @@ function loadEnv(callerImportMetaUrl) {
|
|
|
629
637
|
for (const [key, value] of Object.entries(toEnvRecord(files))) {
|
|
630
638
|
if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key)) process.env[key] = value;
|
|
631
639
|
}
|
|
640
|
+
for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
|
|
641
|
+
if (!process.env[key]) process.env[key] = value;
|
|
642
|
+
}
|
|
632
643
|
if (process.env.D365FO_DEV_ENVIRONMENT_TYPE) {
|
|
633
644
|
process.env.DEV_ENVIRONMENT_TYPE = process.env.D365FO_DEV_ENVIRONMENT_TYPE;
|
|
634
645
|
}
|
|
@@ -1703,13 +1714,13 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1703
1714
|
if (this.dbPath === ":memory:") {
|
|
1704
1715
|
return Promise.reject(new Error("in-memory DB is not visible to worker threads"));
|
|
1705
1716
|
}
|
|
1706
|
-
return new Promise((
|
|
1717
|
+
return new Promise((resolve4, reject) => {
|
|
1707
1718
|
const worker = new Worker(new URL("./symbolCountsWorker.js", import.meta.url), {
|
|
1708
1719
|
workerData: { dbPath: this.dbPath }
|
|
1709
1720
|
});
|
|
1710
1721
|
worker.once("message", (msg) => {
|
|
1711
1722
|
if (msg.ok) {
|
|
1712
|
-
|
|
1723
|
+
resolve4({ total: msg.total, byType: msg.byType });
|
|
1713
1724
|
} else {
|
|
1714
1725
|
reject(new Error(msg.error));
|
|
1715
1726
|
}
|
|
@@ -1867,6 +1878,38 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1867
1878
|
* database (a custom-model build: ~10K of ~1.2M symbols, where the full
|
|
1868
1879
|
* rebuild cost 327s against 5s of actual indexing work).
|
|
1869
1880
|
*/
|
|
1881
|
+
/**
|
|
1882
|
+
* Turn a write failure inside a model transaction into an error that names
|
|
1883
|
+
* the database being written and, when the drive is the likely cause, how
|
|
1884
|
+
* much room is left on it.
|
|
1885
|
+
*
|
|
1886
|
+
* A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
|
|
1887
|
+
* wrapper then fails to COMMIT and the only thing the user sees is
|
|
1888
|
+
* "cannot commit - no transaction is active" with a stack inside the library —
|
|
1889
|
+
* no path, no mention of space. That message sent at least one user hunting
|
|
1890
|
+
* for a corrupt index when the index was simply being written to the wrong
|
|
1891
|
+
* (and nearly full) drive.
|
|
1892
|
+
*/
|
|
1893
|
+
describeWriteFailure(err, model) {
|
|
1894
|
+
const original = err instanceof Error ? err : new Error(String(err));
|
|
1895
|
+
const message = original.message;
|
|
1896
|
+
const diskRelated = /disk is full|SQLITE_FULL|disk I\/O error|no transaction is active/i.test(message);
|
|
1897
|
+
if (!diskRelated) return original;
|
|
1898
|
+
let space = "";
|
|
1899
|
+
try {
|
|
1900
|
+
const stat = fs2.statfsSync(path.dirname(path.resolve(this.dbPath)));
|
|
1901
|
+
const freeGb = Number(stat.bavail) * Number(stat.bsize) / 1024 ** 3;
|
|
1902
|
+
space = ` (${freeGb.toFixed(1)} GB free there)`;
|
|
1903
|
+
} catch {
|
|
1904
|
+
}
|
|
1905
|
+
const wrapped = new Error(
|
|
1906
|
+
`Writing model '${model}' to ${this.dbPath} failed: ${message}
|
|
1907
|
+
The index is written to that path${space}. A full disk makes SQLite roll the write back on its own, which is what surfaces as "cannot commit - no transaction is active".
|
|
1908
|
+
Point the installation at a drive with room (a full index needs several GB): re-run 'd365fo-mcp setup' and choose another directory, or set index.dbPath / index.metadataPath in d365fo-mcp.json.`
|
|
1909
|
+
);
|
|
1910
|
+
wrapped.cause = original;
|
|
1911
|
+
return wrapped;
|
|
1912
|
+
}
|
|
1870
1913
|
async indexMetadataDirectory(metadataPath, modelNames, opts) {
|
|
1871
1914
|
const skipFts = process.env.SKIP_FTS === "true";
|
|
1872
1915
|
const resumable = process.env.RESUME === "true";
|
|
@@ -1984,7 +2027,11 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1984
2027
|
this.flushPropertyStats();
|
|
1985
2028
|
markProgress?.run(model, Date.now());
|
|
1986
2029
|
});
|
|
1987
|
-
|
|
2030
|
+
try {
|
|
2031
|
+
tx();
|
|
2032
|
+
} catch (err) {
|
|
2033
|
+
throw this.describeWriteFailure(err, model);
|
|
2034
|
+
}
|
|
1988
2035
|
const modelDuration = ((Date.now() - modelStartTime) / 1e3).toFixed(1);
|
|
1989
2036
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(0);
|
|
1990
2037
|
if (isCI()) {
|
|
@@ -595,6 +595,14 @@ function toEnvRecord(files) {
|
|
|
595
595
|
}
|
|
596
596
|
return out;
|
|
597
597
|
}
|
|
598
|
+
function defaultPathEnv(baseDir) {
|
|
599
|
+
const out = {};
|
|
600
|
+
for (const setting of SETTINGS) {
|
|
601
|
+
if (setting.type !== "path" || typeof setting.default !== "string" || setting.default === "") continue;
|
|
602
|
+
out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
598
606
|
|
|
599
607
|
// src/utils/loadEnv.ts
|
|
600
608
|
var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
|
|
@@ -629,6 +637,9 @@ function loadEnv(callerImportMetaUrl) {
|
|
|
629
637
|
for (const [key, value] of Object.entries(toEnvRecord(files))) {
|
|
630
638
|
if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key)) process.env[key] = value;
|
|
631
639
|
}
|
|
640
|
+
for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
|
|
641
|
+
if (!process.env[key]) process.env[key] = value;
|
|
642
|
+
}
|
|
632
643
|
if (process.env.D365FO_DEV_ENVIRONMENT_TYPE) {
|
|
633
644
|
process.env.DEV_ENVIRONMENT_TYPE = process.env.D365FO_DEV_ENVIRONMENT_TYPE;
|
|
634
645
|
}
|
|
@@ -116,6 +116,7 @@ Model from .mcp.json; prefix auto-applied from EXTENSION_PREFIX. Classes: member
|
|
|
116
116
|
'add-display-method', 'add-table-method',
|
|
117
117
|
'add-index', 'remove-index',
|
|
118
118
|
'add-relation', 'remove-relation',
|
|
119
|
+
'add-delete-action', 'remove-delete-action',
|
|
119
120
|
'add-field-group', 'remove-field-group', 'add-field-to-field-group',
|
|
120
121
|
'add-field-modification',
|
|
121
122
|
'add-data-source', 'add-control',
|
|
@@ -131,6 +132,7 @@ Model from .mcp.json; prefix auto-applied from EXTENSION_PREFIX. Classes: member
|
|
|
131
132
|
'add-display-method: display method with [SysClientCacheDataMethodAttribute].\n' +
|
|
132
133
|
'add-table-method: canonical find/exist/findByRecId/validateWrite/validateDelete/initValue boilerplate.\n' +
|
|
133
134
|
'add-field-modification: override base-table field label/mandatory in a table-extension.\n' +
|
|
135
|
+
'add-delete-action: table DeleteActions entry — deleteActionName + optional deleteActionTable/deleteActionType (None|Restricted|Cascade|CascadeRestricted).\n' +
|
|
134
136
|
'modify-property: any object-level property (TableGroup, TitleField1, TableType, Extends…) — see propertyPath.'
|
|
135
137
|
},
|
|
136
138
|
params: {
|
|
@@ -74,6 +74,7 @@ export const generateObjectTool = {
|
|
|
74
74
|
description: '[scaffold:table] Storage type: Regular (default, omit), TempDB, InMemory. ⛔ NEVER pass as tableGroup.',
|
|
75
75
|
},
|
|
76
76
|
generateCommonFields: { type: 'boolean', description: '[scaffold:table] Auto-generate common fields based on table group patterns.' },
|
|
77
|
+
preview: { type: 'boolean', description: '[scaffold:table] Return the XML without writing to disk.' },
|
|
77
78
|
dataSource: { type: 'string', description: '[scaffold:form] Optional: Table name for primary datasource.' },
|
|
78
79
|
formPattern: {
|
|
79
80
|
type: 'string',
|
|
@@ -92,7 +93,7 @@ export const generateObjectTool = {
|
|
|
92
93
|
generateControls: { type: 'boolean', description: '[scaffold:form] Auto-generate grid controls for datasource.' },
|
|
93
94
|
fields: {
|
|
94
95
|
type: 'array',
|
|
95
|
-
description: '[scaffold:report | fields] Structured field specs
|
|
96
|
+
description: '[scaffold:table|report | fields] Structured field specs; takes priority over fieldsHint. PREFER for enum-backed fields or an explicit EDT — a bare name cannot express either. name + optional edt/enumType/type/label/mandatory (EDT auto-resolved when omitted).',
|
|
96
97
|
items: {
|
|
97
98
|
type: 'object',
|
|
98
99
|
properties: {
|
|
@@ -42,6 +42,19 @@ export const D365FO_FILE_PARAM_SPECS = {
|
|
|
42
42
|
type: 'string',
|
|
43
43
|
description: 'Replacement for the first occurrence of oldCode; pass "" to delete the snippet.',
|
|
44
44
|
},
|
|
45
|
+
// table delete actions
|
|
46
|
+
deleteActionName: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Delete action name — conventionally the related table name.',
|
|
49
|
+
},
|
|
50
|
+
deleteActionTable: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Related table the delete action applies to (defaults to deleteActionName).',
|
|
53
|
+
},
|
|
54
|
+
deleteActionType: {
|
|
55
|
+
type: 'string (None | Restricted | Cascade | CascadeRestricted)',
|
|
56
|
+
description: 'Delete action to take on the related table. Defaults to Restricted.',
|
|
57
|
+
},
|
|
45
58
|
// table fields
|
|
46
59
|
fieldName: { type: 'string', description: 'Field name.' },
|
|
47
60
|
fieldNewName: {
|
|
@@ -269,6 +282,12 @@ export const D365FO_FILE_OP_SPECS = {
|
|
|
269
282
|
optional: ['relationConstraints', 'relationCardinality', 'relatedTableCardinality', 'relationshipType'],
|
|
270
283
|
},
|
|
271
284
|
'remove-relation': { required: ['relationName'], optional: [] },
|
|
285
|
+
'add-delete-action': {
|
|
286
|
+
required: ['deleteActionName'],
|
|
287
|
+
optional: ['deleteActionTable', 'deleteActionType'],
|
|
288
|
+
note: 'objectType="table" only. deleteActionTable defaults to deleteActionName; deleteActionType defaults to Restricted.',
|
|
289
|
+
},
|
|
290
|
+
'remove-delete-action': { required: ['deleteActionName'], optional: [] },
|
|
272
291
|
'add-field-group': {
|
|
273
292
|
required: ['fieldGroupName'],
|
|
274
293
|
optional: ['fieldGroupFields', 'fieldGroupLabel'],
|
|
@@ -16,19 +16,7 @@
|
|
|
16
16
|
import type { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
17
17
|
import type { XppServerContext } from '../types/context.js';
|
|
18
18
|
export declare function generateObjectTool(request: CallToolRequest, context: XppServerContext): Promise<{
|
|
19
|
-
content: {
|
|
20
|
-
type: string;
|
|
21
|
-
text: string;
|
|
22
|
-
}[];
|
|
23
|
-
isError: boolean;
|
|
24
|
-
} | {
|
|
25
|
-
isError?: undefined;
|
|
26
19
|
content: any;
|
|
27
|
-
} | {
|
|
28
|
-
content: {
|
|
29
|
-
type: 'text';
|
|
30
|
-
text: string;
|
|
31
|
-
}[];
|
|
32
20
|
isError?: boolean | undefined;
|
|
33
21
|
}>;
|
|
34
22
|
//# sourceMappingURL=generateObject.d.ts.map
|
|
@@ -19,14 +19,8 @@ export type GenerateSmartType = (typeof GENERATE_SMART_TYPES)[number];
|
|
|
19
19
|
type SmartHandler = (args: any, context: XppServerContext) => Promise<any>;
|
|
20
20
|
export declare const GENERATE_SMART_DISPATCH: Record<GenerateSmartType, SmartHandler>;
|
|
21
21
|
export declare function generateSmartTool(request: CallToolRequest, context: XppServerContext): Promise<{
|
|
22
|
-
content: {
|
|
23
|
-
type: string;
|
|
24
|
-
text: string;
|
|
25
|
-
}[];
|
|
26
|
-
isError: boolean;
|
|
27
|
-
} | {
|
|
28
|
-
isError?: undefined;
|
|
29
22
|
content: any;
|
|
23
|
+
isError?: boolean | undefined;
|
|
30
24
|
}>;
|
|
31
25
|
export {};
|
|
32
26
|
//# sourceMappingURL=generateSmart.d.ts.map
|
|
@@ -45,7 +45,11 @@ export async function generateSmartTool(request, context) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
const result = await handler(rest, context);
|
|
48
|
-
|
|
48
|
+
// Preserve isError — dropping it reported a rejected generation as a success.
|
|
49
|
+
return {
|
|
50
|
+
content: result?.content ?? [{ type: 'text', text: 'No results returned' }],
|
|
51
|
+
...(result?.isError ? { isError: true } : {}),
|
|
52
|
+
};
|
|
49
53
|
}
|
|
50
54
|
// Tool registration (name, description, inputSchema) lives inline in
|
|
51
55
|
// src/server/mcpServer.ts — the single source of truth for tool instructions.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import { XppSymbolIndex } from '../metadata/symbolIndex.js';
|
|
7
|
+
import { TableFieldSpec } from '../utils/smartXmlBuilder.js';
|
|
7
8
|
import type { BridgeClient } from '../bridge/bridgeClient.js';
|
|
8
9
|
interface GenerateSmartTableArgs {
|
|
9
10
|
name: string;
|
|
@@ -20,8 +21,15 @@ interface GenerateSmartTableArgs {
|
|
|
20
21
|
tableType?: string;
|
|
21
22
|
copyFrom?: string;
|
|
22
23
|
fieldsHint?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Structured field specs. Takes priority over `fieldsHint`, which can only carry
|
|
26
|
+
* names and therefore cannot express enum-backed fields or an explicit EDT.
|
|
27
|
+
*/
|
|
28
|
+
fields?: TableFieldSpec[];
|
|
23
29
|
primaryKeyFields?: string[];
|
|
24
30
|
generateCommonFields?: boolean;
|
|
31
|
+
/** Return the XML without touching disk. The default Windows path writes the file (eval #21). */
|
|
32
|
+
preview?: boolean;
|
|
25
33
|
modelName?: string;
|
|
26
34
|
projectPath?: string;
|
|
27
35
|
solutionPath?: string;
|
|
@@ -118,7 +118,7 @@ export const generateSmartTableTool = {
|
|
|
118
118
|
},
|
|
119
119
|
};
|
|
120
120
|
export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
121
|
-
const { name, label, tableGroup = 'Main', tableType, copyFrom, fieldsHint, primaryKeyFields, generateCommonFields, modelName, projectPath, solutionPath, packagePath: argPackagePath, methods: requestedMethods, } = args;
|
|
121
|
+
const { name, label, tableGroup = 'Main', tableType, copyFrom, fieldsHint, fields: fieldSpecs, primaryKeyFields, generateCommonFields, preview, modelName, projectPath, solutionPath, packagePath: argPackagePath, methods: requestedMethods, } = args;
|
|
122
122
|
// Guard: 'TempDB' and 'InMemory' are NOT valid TableGroup values.
|
|
123
123
|
if (tableGroup === 'TempDB' || tableGroup === 'InMemory') {
|
|
124
124
|
return {
|
|
@@ -200,9 +200,10 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
|
200
200
|
throw new Error(`Failed to copy structure from ${copyFrom}: ${error instanceof Error ? error.message : String(error)}`);
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
const explicitFields = Array.isArray(fieldSpecs) ? fieldSpecs.filter(f => f?.name) : [];
|
|
203
204
|
// Strategy 2: Generate common fields based on table group patterns.
|
|
204
|
-
// Skipped when
|
|
205
|
-
if (generateCommonFields && !copyFrom && !fieldsHint) {
|
|
205
|
+
// Skipped when the caller stated the fields (fields[] or fieldsHint).
|
|
206
|
+
if (generateCommonFields && !copyFrom && !fieldsHint && explicitFields.length === 0) {
|
|
206
207
|
console.log(`[generateSmartTable] Analyzing patterns for table group: ${tableGroup}`);
|
|
207
208
|
try {
|
|
208
209
|
const db = symbolIndex.getReadDb();
|
|
@@ -258,15 +259,16 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
|
258
259
|
// Continue without pattern-based fields
|
|
259
260
|
}
|
|
260
261
|
}
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
262
|
+
// Guard: reject reserved system field names before any generation attempt.
|
|
263
|
+
// Adding a field named e.g. "CreatedDateTime" produces a compiler error:
|
|
264
|
+
// "Invalid field name; 'CreatedDateTime' is reserved for system fields."
|
|
265
|
+
// The platform auto-provides these fields; users should NOT declare them.
|
|
266
|
+
{
|
|
267
|
+
const declaredNames = explicitFields.length > 0
|
|
268
|
+
? explicitFields.map(f => f.name)
|
|
269
|
+
: (fieldsHint && !copyFrom ? fieldsHint.split(',').map(s => s.trim()).filter(s => s.length > 0) : []);
|
|
270
|
+
const sourceParam = explicitFields.length > 0 ? 'fields' : 'fieldsHint';
|
|
271
|
+
const reservedHits = declaredNames.filter(f => RESERVED_SYSTEM_FIELD_NAMES.has(f.toLowerCase()));
|
|
270
272
|
if (reservedHits.length > 0) {
|
|
271
273
|
return {
|
|
272
274
|
content: [{
|
|
@@ -295,12 +297,31 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
|
295
297
|
return ` • "${f}" → ${suggestions[f.toLowerCase()] ?? '"CustomFieldName"'}`;
|
|
296
298
|
}).join('\n')}`,
|
|
297
299
|
``,
|
|
298
|
-
`🔄 **Call generate again** with the corrected
|
|
300
|
+
`🔄 **Call generate again** with the corrected \`${sourceParam}\`.`,
|
|
299
301
|
].join('\n'),
|
|
300
302
|
}],
|
|
301
303
|
isError: true,
|
|
302
304
|
};
|
|
303
305
|
}
|
|
306
|
+
}
|
|
307
|
+
// Strategy 3a: structured field specs. Preferred over fieldsHint — an enum-backed
|
|
308
|
+
// field or an explicit EDT cannot be expressed by a bare name (eval #21).
|
|
309
|
+
if (explicitFields.length > 0 && !copyFrom) {
|
|
310
|
+
const specDb = symbolIndex.getReadDb();
|
|
311
|
+
for (const spec of explicitFields) {
|
|
312
|
+
// enumType and an explicit type are authoritative; only resolve an EDT when
|
|
313
|
+
// neither was given and the caller did not name one.
|
|
314
|
+
const edt = spec.enumType || spec.type
|
|
315
|
+
? spec.edt
|
|
316
|
+
: (spec.edt ?? resolveBestEdt(spec.name, specDb));
|
|
317
|
+
fields.push({ ...spec, edt });
|
|
318
|
+
}
|
|
319
|
+
console.log(`[generateSmartTable] Added ${explicitFields.length} fields from fields[]`);
|
|
320
|
+
}
|
|
321
|
+
// Strategy 3b: Parse field hints and suggest EDTs
|
|
322
|
+
if (fieldsHint && !copyFrom && explicitFields.length === 0) {
|
|
323
|
+
console.log(`[generateSmartTable] Parsing field hints: ${fieldsHint}`);
|
|
324
|
+
const hintFields = fieldsHint.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
304
325
|
const hintDb = symbolIndex.getReadDb();
|
|
305
326
|
for (const hint of hintFields) {
|
|
306
327
|
// On duplicate hint names, suffix the second occurrence instead of dropping it.
|
|
@@ -455,7 +476,9 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
|
455
476
|
const resolvedPackagePath = argPackagePath || customPackagesRoot || configManager.getPackagePath();
|
|
456
477
|
// getPackagePath() already probes C:\ and K:\ well-known locations before returning null,
|
|
457
478
|
// so reaching here with null means neither location exists on this machine.
|
|
458
|
-
|
|
479
|
+
// preview never writes, so it must not require a write location — demanding one
|
|
480
|
+
// made "just show me the XML" fail on any machine without a D365FO install.
|
|
481
|
+
if (!resolvedPackagePath && process.platform === 'win32' && !preview) {
|
|
459
482
|
throw new Error('\u274c Cannot determine PackagesLocalDirectory path.\n\n' +
|
|
460
483
|
'Neither C:\\AosService\\PackagesLocalDirectory nor K:\\AosService\\PackagesLocalDirectory were found.\n\n' +
|
|
461
484
|
'If your D365FO installation is on a different drive, add one of the following to your .mcp.json:\n' +
|
|
@@ -635,9 +658,9 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
|
635
658
|
isError: true,
|
|
636
659
|
};
|
|
637
660
|
}
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
if (isNonWindows) {
|
|
661
|
+
// Text-only path: non-Windows (bridge unavailable, the local companion writes the file)
|
|
662
|
+
// or an explicit preview=true, which is the only way to scaffold without a disk write.
|
|
663
|
+
if (isNonWindows || preview) {
|
|
641
664
|
const xml = builder.buildTableXml({
|
|
642
665
|
name: finalName,
|
|
643
666
|
label: label || finalName,
|
|
@@ -679,7 +702,9 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
|
|
|
679
702
|
edtWarningBlock,
|
|
680
703
|
noModelNote,
|
|
681
704
|
``,
|
|
682
|
-
|
|
705
|
+
preview && !isNonWindows
|
|
706
|
+
? `ℹ️ preview=true — nothing was written to disk.`
|
|
707
|
+
: `ℹ️ MCP server is running on Azure/Linux — file writing is handled by the local Windows companion. This is the expected hybrid workflow.`,
|
|
683
708
|
nextStep,
|
|
684
709
|
``,
|
|
685
710
|
`\`\`\`xml`,
|
|
@@ -75,6 +75,8 @@ export declare function extractMethodNameFromSource(source: string | undefined):
|
|
|
75
75
|
* Anything unrecognised returns undefined so the op's own default applies.
|
|
76
76
|
*/
|
|
77
77
|
export declare function coerceNoYesFlag(value: unknown): boolean | undefined;
|
|
78
|
+
/** DeleteAction values accepted by the AxTable serialiser. */
|
|
79
|
+
export declare const DELETE_ACTION_TYPES: readonly ['None', 'Restricted', 'Cascade', 'CascadeRestricted'];
|
|
78
80
|
/**
|
|
79
81
|
* Writes the relation properties the bridge drops into an <AxTableRelation>.
|
|
80
82
|
*
|
|
@@ -19,6 +19,7 @@ import { ProjectFileManager, ProjectFileFinder } from './createD365File.js';
|
|
|
19
19
|
import { heuristicEdtBaseType } from './generateSmartTable.js';
|
|
20
20
|
import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
|
|
21
21
|
import { upsertAxTableProperty, AX_TABLE_NON_EXISTENT_PROPERTIES, } from '../utils/axTablePropertyOrder.js';
|
|
22
|
+
import { upsertAxFormDesignProperty } from '../utils/axFormDesignProperties.js';
|
|
22
23
|
import { enforceGrounding } from '../utils/provenanceStore.js';
|
|
23
24
|
import { gateOnReferenceErrors } from './resolveReferences.js';
|
|
24
25
|
import { checkAddControlAgainstParentPattern, isFormPatternEnforceEnabled, } from './validateFormPattern.js';
|
|
@@ -303,6 +304,17 @@ async function directXmlModifyProperty(filePath, propertyPath, propertyValue) {
|
|
|
303
304
|
const escapedValue = String(propertyValue)
|
|
304
305
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
305
306
|
const tagName = propertyPath.split(/[./]/).pop();
|
|
307
|
+
// Forms first: the bridge refuses modify-property for AxForm entirely, and the
|
|
308
|
+
// generic path below cannot serve Design properties either — Caption/Style also
|
|
309
|
+
// occur on controls, so it sees several matches and refuses (#37).
|
|
310
|
+
const formPatched = upsertAxFormDesignProperty(content, tagName, escapedValue);
|
|
311
|
+
if (formPatched) {
|
|
312
|
+
await fs.writeFile(filePath, normalizeD365Xml(formPatched), 'utf-8');
|
|
313
|
+
return {
|
|
314
|
+
success: true,
|
|
315
|
+
message: `✅ Form Design property '${tagName}'='${propertyValue}' set via direct XML (the bridge does not support modify-property for forms). File: ${filePath}`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
306
318
|
const openTagRe = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?</${tagName}>`, 'g');
|
|
307
319
|
const selfClosingRe = new RegExp(`<${tagName}\\b([^>]*)/>`, 'g');
|
|
308
320
|
const openMatches = content.match(openTagRe) ?? [];
|
|
@@ -608,6 +620,71 @@ async function directXmlAddIndex(filePath, indexName, fields, allowDuplicates, a
|
|
|
608
620
|
return null;
|
|
609
621
|
}
|
|
610
622
|
}
|
|
623
|
+
/** DeleteAction values accepted by the AxTable serialiser. */
|
|
624
|
+
export const DELETE_ACTION_TYPES = ['None', 'Restricted', 'Cascade', 'CascadeRestricted'];
|
|
625
|
+
/**
|
|
626
|
+
* add-delete-action / remove-delete-action on a TABLE, written straight to the XML.
|
|
627
|
+
*
|
|
628
|
+
* There is no bridge operation for DeleteActions at all (finding #36), so a
|
|
629
|
+
* cascading delete action was inexpressible through the modify surface — the only
|
|
630
|
+
* route was the forbidden whole-file overwrite. <DeleteActions> is a collection
|
|
631
|
+
* sibling, not part of the order-sensitive top-level property block, so patching
|
|
632
|
+
* it in place is safe. Shape matches MetadataWriteService.cs: Name, Table,
|
|
633
|
+
* DeleteAction.
|
|
634
|
+
*/
|
|
635
|
+
async function directXmlDeleteAction(filePath, mode, name, table, deleteAction) {
|
|
636
|
+
try {
|
|
637
|
+
const rawContent = await fs.readFile(filePath, 'utf-8');
|
|
638
|
+
const content = rawContent.replace(/^/, '').replace(/\r\n/g, '\n');
|
|
639
|
+
// Only tables carry <DeleteActions> — bail on any other shape so a mis-typed
|
|
640
|
+
// objectType never corrupts a non-table file.
|
|
641
|
+
if (!/<AxTable\b/.test(content))
|
|
642
|
+
return null;
|
|
643
|
+
const blockRe = new RegExp(`[\\t ]*<AxTableDeleteAction>\\s*<Name>${escapeRegExp(name)}</Name>[\\s\\S]*?</AxTableDeleteAction>\\n?`);
|
|
644
|
+
const existing = blockRe.exec(content);
|
|
645
|
+
if (mode === 'remove') {
|
|
646
|
+
if (!existing) {
|
|
647
|
+
return { success: true, message: `✅ Delete action '${name}' not present in ${filePath} — nothing to remove.` };
|
|
648
|
+
}
|
|
649
|
+
const updated = content.replace(blockRe, '');
|
|
650
|
+
await fs.writeFile(filePath, normalizeD365Xml(updated), 'utf-8');
|
|
651
|
+
return { success: true, message: `✅ Delete action '${name}' removed. File: ${filePath}` };
|
|
652
|
+
}
|
|
653
|
+
if (existing) {
|
|
654
|
+
return { success: true, message: `✅ Delete action '${name}' already present in ${filePath} — skipped (idempotent).` };
|
|
655
|
+
}
|
|
656
|
+
const newElement = `\t\t<AxTableDeleteAction>\n` +
|
|
657
|
+
`\t\t\t<Name>${name}</Name>\n` +
|
|
658
|
+
`\t\t\t<Table>${table ?? name}</Table>\n` +
|
|
659
|
+
`\t\t\t<DeleteAction>${deleteAction ?? 'Restricted'}</DeleteAction>\n` +
|
|
660
|
+
`\t\t</AxTableDeleteAction>`;
|
|
661
|
+
let updated;
|
|
662
|
+
if (content.includes('<DeleteActions />')) {
|
|
663
|
+
updated = content.replace('<DeleteActions />', `<DeleteActions>\n${newElement}\n\t</DeleteActions>`);
|
|
664
|
+
}
|
|
665
|
+
else if (content.includes('</DeleteActions>')) {
|
|
666
|
+
updated = content.replace('</DeleteActions>', `${newElement}\n\t</DeleteActions>`);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
// No <DeleteActions> collection at all — not a shape we can safely patch.
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
if (updated === content)
|
|
673
|
+
return null;
|
|
674
|
+
await fs.writeFile(filePath, normalizeD365Xml(updated), 'utf-8');
|
|
675
|
+
return {
|
|
676
|
+
success: true,
|
|
677
|
+
message: `✅ Delete action '${name}' (${deleteAction ?? 'Restricted'} on ${table ?? name}) added. File: ${filePath}`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
console.error(`[modify_d365fo_file] directXmlDeleteAction failed: ${err}`);
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function escapeRegExp(s) {
|
|
686
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
687
|
+
}
|
|
611
688
|
/**
|
|
612
689
|
* Writes the relation properties the bridge drops into an <AxTableRelation>.
|
|
613
690
|
*
|
|
@@ -749,6 +826,7 @@ const ModifyD365FileArgsSchema = z.object({
|
|
|
749
826
|
'add-field', 'modify-field', 'rename-field', 'replace-all-fields', 'remove-field',
|
|
750
827
|
'add-index', 'remove-index',
|
|
751
828
|
'add-relation', 'remove-relation',
|
|
829
|
+
'add-delete-action', 'remove-delete-action',
|
|
752
830
|
'add-field-group', 'remove-field-group', 'add-field-to-field-group',
|
|
753
831
|
'add-field-modification',
|
|
754
832
|
'add-data-source',
|
|
@@ -880,6 +958,10 @@ const ModifyD365FileArgsSchema = z.object({
|
|
|
880
958
|
relationCardinality: z.string().optional().describe('Cardinality on local side: ZeroMore | ZeroOne | ExactlyOne (default: ZeroMore).'),
|
|
881
959
|
relatedTableCardinality: z.string().optional().describe('Cardinality on related side: ZeroMore | ZeroOne | ExactlyOne (default: ExactlyOne).'),
|
|
882
960
|
relationshipType: z.string().optional().describe('Relationship type: Association | Composition | Aggregation | Link | Specialization (default: Association).'),
|
|
961
|
+
// For add-delete-action / remove-delete-action (table)
|
|
962
|
+
deleteActionTable: z.string().optional().describe('Related table the delete action applies to (e.g. "SalesLine"). Defaults to deleteActionName.'),
|
|
963
|
+
deleteActionName: z.string().optional().describe('Delete action name — conventionally the related table name. Required for both add- and remove-delete-action.'),
|
|
964
|
+
deleteActionType: z.enum(DELETE_ACTION_TYPES).optional().describe('None | Restricted (default) | Cascade | CascadeRestricted.'),
|
|
883
965
|
// For add-field-group / remove-field-group / add-field-to-field-group (table, table-extension)
|
|
884
966
|
fieldGroupName: z.string().optional().describe('Field group name. For add-field-to-field-group in a table-extension: name of the group (new or existing base-table group).'),
|
|
885
967
|
fieldGroupFields: z.array(z.string()).optional().describe('Initial field names for add-field-group. Can be empty — add fields later with add-field-to-field-group.'),
|
|
@@ -1464,6 +1546,15 @@ export async function modifyD365FileTool(request, context) {
|
|
|
1464
1546
|
}
|
|
1465
1547
|
break;
|
|
1466
1548
|
}
|
|
1549
|
+
case 'add-delete-action':
|
|
1550
|
+
case 'remove-delete-action': {
|
|
1551
|
+
// No bridge op exists for DeleteActions (#36) — this is the only path.
|
|
1552
|
+
const daName = args.deleteActionName ?? args.deleteActionTable;
|
|
1553
|
+
if (daName) {
|
|
1554
|
+
bridgeResult = await directXmlDeleteAction(actualFilePath, operation === 'add-delete-action' ? 'add' : 'remove', daName, args.deleteActionTable, args.deleteActionType);
|
|
1555
|
+
}
|
|
1556
|
+
break;
|
|
1557
|
+
}
|
|
1467
1558
|
case 'add-field-group': {
|
|
1468
1559
|
if (args.fieldGroupName) {
|
|
1469
1560
|
bridgeResult = await bridgeAddFieldGroup(context.bridge, objectName, args.fieldGroupName, args.fieldGroupLabel, args.fieldGroupFields);
|
|
@@ -443,8 +443,9 @@ function checkGenericDocComment(code) {
|
|
|
443
443
|
}
|
|
444
444
|
/**
|
|
445
445
|
* XML001 — AxTable XML missing an index with <AlternateKey>Yes</AlternateKey>.
|
|
446
|
-
*
|
|
447
|
-
*
|
|
446
|
+
* Warning, not error: xppbp raises BPCheckAlternateKeyAbsent as a warning and the
|
|
447
|
+
* table still builds. As an error it made a legitimately single-index table
|
|
448
|
+
* unsatisfiable (eval #7).
|
|
448
449
|
*/
|
|
449
450
|
function checkMissingAlternateKey(code) {
|
|
450
451
|
const violations = [];
|
|
@@ -458,12 +459,11 @@ function checkMissingAlternateKey(code) {
|
|
|
458
459
|
if (!/<AlternateKey>\s*Yes\s*<\/AlternateKey>/i.test(code)) {
|
|
459
460
|
violations.push({
|
|
460
461
|
rule: 'XML001',
|
|
461
|
-
severity: '
|
|
462
|
+
severity: 'warning',
|
|
462
463
|
excerpt: '<AxTable> — no index with <AlternateKey>Yes</AlternateKey>',
|
|
463
|
-
fix: 'Add
|
|
464
|
-
'
|
|
465
|
-
'
|
|
466
|
-
'generate_smart adds this automatically via buildPrimaryKeyIndex.',
|
|
464
|
+
fix: 'Add an <AxTableIndex> with <AlternateKey>Yes</AlternateKey> unless the table ' +
|
|
465
|
+
'deliberately has none — xppbp reports BPCheckAlternateKeyAbsent as a warning and ' +
|
|
466
|
+
'the table still builds. generate_smart adds one via buildPrimaryKeyIndex.',
|
|
467
467
|
});
|
|
468
468
|
}
|
|
469
469
|
return violations;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AxForm <Design> property upsert.
|
|
3
|
+
*
|
|
4
|
+
* The C# bridge rejects modify-property for forms outright ("modify-property not
|
|
5
|
+
* supported for objectType form via bridge"), so the Design annotations
|
|
6
|
+
* object_patterns(action="spec") prescribes — Pattern / PatternVersion / Style —
|
|
7
|
+
* could not be set through any grounded path (findings #37, corpus
|
|
8
|
+
* 2026-07-22T04__L2-form-over-view). The generic directXmlModifyProperty cannot
|
|
9
|
+
* serve them either: Caption/Style also occur on controls, so it sees several
|
|
10
|
+
* matches and refuses to guess.
|
|
11
|
+
*
|
|
12
|
+
* Ground truth for shape and order: eval/goldens/L1-form-listpage (VM-captured,
|
|
13
|
+
* built clean) — Design's direct-child properties carry `xmlns=""`, are
|
|
14
|
+
* alphabetical, and <Controls> terminates the block.
|
|
15
|
+
*/
|
|
16
|
+
/** Direct-child properties of <Design> the serialiser writes. */
|
|
17
|
+
export declare const AX_FORM_DESIGN_PROPERTIES: Set<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Set a form Design property, inserting it in alphabetical order when absent.
|
|
20
|
+
* Returns the updated XML, or null when the document is not an AxForm, has no
|
|
21
|
+
* <Design>, or the property is not a Design property (so the caller can surface
|
|
22
|
+
* the original error rather than write something invented).
|
|
23
|
+
*/
|
|
24
|
+
export declare function upsertAxFormDesignProperty(xml: string, property: string, value: string): string | null;
|
|
25
|
+
//# sourceMappingURL=axFormDesignProperties.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AxForm <Design> property upsert.
|
|
3
|
+
*
|
|
4
|
+
* The C# bridge rejects modify-property for forms outright ("modify-property not
|
|
5
|
+
* supported for objectType form via bridge"), so the Design annotations
|
|
6
|
+
* object_patterns(action="spec") prescribes — Pattern / PatternVersion / Style —
|
|
7
|
+
* could not be set through any grounded path (findings #37, corpus
|
|
8
|
+
* 2026-07-22T04__L2-form-over-view). The generic directXmlModifyProperty cannot
|
|
9
|
+
* serve them either: Caption/Style also occur on controls, so it sees several
|
|
10
|
+
* matches and refuses to guess.
|
|
11
|
+
*
|
|
12
|
+
* Ground truth for shape and order: eval/goldens/L1-form-listpage (VM-captured,
|
|
13
|
+
* built clean) — Design's direct-child properties carry `xmlns=""`, are
|
|
14
|
+
* alphabetical, and <Controls> terminates the block.
|
|
15
|
+
*/
|
|
16
|
+
/** Direct-child properties of <Design> the serialiser writes. */
|
|
17
|
+
export const AX_FORM_DESIGN_PROPERTIES = new Set([
|
|
18
|
+
'Caption',
|
|
19
|
+
'ColumnsMode',
|
|
20
|
+
'DataSource',
|
|
21
|
+
'Pattern',
|
|
22
|
+
'PatternVersion',
|
|
23
|
+
'ShowDeleteButton',
|
|
24
|
+
'ShowNewButton',
|
|
25
|
+
'Style',
|
|
26
|
+
'TitleDataSource',
|
|
27
|
+
'ViewEditMode',
|
|
28
|
+
'WindowResize',
|
|
29
|
+
'WindowType',
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Set a form Design property, inserting it in alphabetical order when absent.
|
|
33
|
+
* Returns the updated XML, or null when the document is not an AxForm, has no
|
|
34
|
+
* <Design>, or the property is not a Design property (so the caller can surface
|
|
35
|
+
* the original error rather than write something invented).
|
|
36
|
+
*/
|
|
37
|
+
export function upsertAxFormDesignProperty(xml, property, value) {
|
|
38
|
+
if (!/<AxForm[\s>]/.test(xml))
|
|
39
|
+
return null;
|
|
40
|
+
if (!AX_FORM_DESIGN_PROPERTIES.has(property))
|
|
41
|
+
return null;
|
|
42
|
+
const designStart = xml.search(/^[ \t]*<Design>/m);
|
|
43
|
+
if (designStart === -1)
|
|
44
|
+
return null;
|
|
45
|
+
const designOpen = xml.indexOf('>', designStart) + 1;
|
|
46
|
+
// Everything before <Controls> (or </Design> on a control-less form) is the
|
|
47
|
+
// direct-child property region — nested controls carry their own Caption/Style,
|
|
48
|
+
// and must never be touched.
|
|
49
|
+
const controlsRel = xml.slice(designOpen).search(/^[ \t]*<Controls[ />]/m);
|
|
50
|
+
const endRel = controlsRel !== -1 ? controlsRel : xml.slice(designOpen).search(/^[ \t]*<\/Design>/m);
|
|
51
|
+
if (endRel === -1)
|
|
52
|
+
return null;
|
|
53
|
+
const propsEnd = designOpen + endRel;
|
|
54
|
+
const head = xml.slice(0, designOpen);
|
|
55
|
+
const region = xml.slice(designOpen, propsEnd);
|
|
56
|
+
const tail = xml.slice(propsEnd);
|
|
57
|
+
const designIndent = /^([ \t]*)<Design>/m.exec(xml.slice(designStart))?.[1] ?? '\t';
|
|
58
|
+
const indent = `${designIndent}\t`;
|
|
59
|
+
const element = `${indent}<${property} xmlns="">${value}</${property}>\n`;
|
|
60
|
+
const existing = new RegExp(`^[ \\t]*<${property}\\b[^>]*?/>[ \\t]*\\n|^[ \\t]*<${property}\\b[^>]*>[\\s\\S]*?</${property}>[ \\t]*\\n`, 'm');
|
|
61
|
+
if (existing.test(region)) {
|
|
62
|
+
return head + region.replace(existing, element) + tail;
|
|
63
|
+
}
|
|
64
|
+
// Insert before the first existing property that sorts after this one.
|
|
65
|
+
const propLine = /^[ \t]*<([A-Za-z_][\w.-]*)\b/gm;
|
|
66
|
+
let insertAt = null;
|
|
67
|
+
for (let m = propLine.exec(region); m; m = propLine.exec(region)) {
|
|
68
|
+
if (AX_FORM_DESIGN_PROPERTIES.has(m[1]) && m[1].localeCompare(property) > 0) {
|
|
69
|
+
insertAt = m.index;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const patched = insertAt === null
|
|
74
|
+
? region.replace(/\s*$/, '\n') + element
|
|
75
|
+
: region.slice(0, insertAt) + element + region.slice(insertAt);
|
|
76
|
+
return head + patched + tail;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=axFormDesignProperties.js.map
|
package/dist/utils/loadEnv.d.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* caller picked on purpose)
|
|
11
11
|
* 3. config/d365fo-mcp.json + config/secrets.json
|
|
12
12
|
* 4. the ambient repo-root .env (pre-wizard installations keep working)
|
|
13
|
+
* 5. the built-in defaults for path settings, resolved against the
|
|
14
|
+
* installation directory (see defaultPathEnv)
|
|
13
15
|
*
|
|
14
16
|
* Multiple instances run from one source folder by pointing each at its own
|
|
15
17
|
* config or .env file:
|
package/dist/utils/loadEnv.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* caller picked on purpose)
|
|
11
11
|
* 3. config/d365fo-mcp.json + config/secrets.json
|
|
12
12
|
* 4. the ambient repo-root .env (pre-wizard installations keep working)
|
|
13
|
+
* 5. the built-in defaults for path settings, resolved against the
|
|
14
|
+
* installation directory (see defaultPathEnv)
|
|
13
15
|
*
|
|
14
16
|
* Multiple instances run from one source folder by pointing each at its own
|
|
15
17
|
* config or .env file:
|
|
@@ -25,7 +27,7 @@ import dotenv from 'dotenv';
|
|
|
25
27
|
import { existsSync } from 'fs';
|
|
26
28
|
import { dirname, isAbsolute, join, resolve } from 'path';
|
|
27
29
|
import { fileURLToPath } from 'url';
|
|
28
|
-
import { resolveConfigFiles, toEnvRecord } from '../config/configFile.js';
|
|
30
|
+
import { defaultPathEnv, resolveConfigFiles, toEnvRecord } from '../config/configFile.js';
|
|
29
31
|
/** Env vars whose relative paths should resolve from the .env file directory. */
|
|
30
32
|
const PATH_VARS = ['DB_PATH', 'LABELS_DB_PATH', 'METADATA_PATH'];
|
|
31
33
|
/**
|
|
@@ -109,6 +111,14 @@ export function loadEnv(callerImportMetaUrl) {
|
|
|
109
111
|
if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key))
|
|
110
112
|
process.env[key] = value;
|
|
111
113
|
}
|
|
114
|
+
// Lowest precedence of all: the defaults for the path settings nobody writes
|
|
115
|
+
// out, anchored to the installation directory rather than process.cwd(). See
|
|
116
|
+
// defaultPathEnv — without this the index of an npm install is built beside
|
|
117
|
+
// the package instead of in the directory chosen during setup.
|
|
118
|
+
for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
|
|
119
|
+
if (!process.env[key])
|
|
120
|
+
process.env[key] = value;
|
|
121
|
+
}
|
|
112
122
|
// Bridge the public D365FO_-prefixed setting name to the internal
|
|
113
123
|
// DEV_ENVIRONMENT_TYPE that consumers read. Prefixed wins when both are set;
|
|
114
124
|
// a lone plain entry is tolerated for backward compatibility.
|