@xrmforge/devkit 0.7.23 → 0.7.25
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/LICENSE +21 -21
- package/README.md +123 -0
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/templates/AGENT.md +33 -8
- package/dist/templates/constants.ts +32 -32
- package/dist/templates/error-handler.ts +96 -96
- package/dist/templates/eslint.config.js +21 -21
- package/dist/templates/example-form.test.ts +33 -33
- package/dist/templates/example-form.ts +77 -77
- package/dist/templates/logger.ts +67 -67
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 XrmForge Contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 XrmForge Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @xrmforge/devkit
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@xrmforge/devkit)
|
|
4
|
+
[](https://github.com/juergenbeck/XrmForge/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
**Build orchestration and project scaffolding for Dynamics 365 Web Resources.** Wraps esbuild to produce IIFE bundles with named globals (the format D365 needs for form event binding) from a declarative config -- no `esbuild.config.js`, no `build.mjs`.
|
|
7
|
+
|
|
8
|
+
> Part of [XrmForge](https://github.com/juergenbeck/XrmForge#readme). This package powers `xrmforge build` and `xrmforge init`. Most users drive it through [`@xrmforge/cli`](https://www.npmjs.com/package/@xrmforge/cli); install it directly only to embed builds or scaffolding in your own tooling.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install --save-dev @xrmforge/devkit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Requirements:** Node.js 20 or higher. Bundles `esbuild` as a dependency.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Declarative build config
|
|
23
|
+
|
|
24
|
+
Builds are driven by the `build` section of `xrmforge.config.json`. Each entry becomes one IIFE Web Resource exposed under a global namespace.
|
|
25
|
+
|
|
26
|
+
```jsonc
|
|
27
|
+
{
|
|
28
|
+
"build": {
|
|
29
|
+
"outDir": "./dist/contoso_/JS", // default: ./dist
|
|
30
|
+
"target": "es2020", // default: es2020
|
|
31
|
+
"sourcemap": true, // default: true
|
|
32
|
+
"minify": true, // default: false
|
|
33
|
+
"entries": {
|
|
34
|
+
"account_form": {
|
|
35
|
+
"input": "./src/forms/account-form.ts", // required
|
|
36
|
+
"namespace": "Contoso.AccountForm", // required: D365 global for event binding
|
|
37
|
+
"out": "Account/OnLoad.js" // optional: defaults to <entry-key>.js
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Field | Required | Default | Notes |
|
|
45
|
+
|-------|----------|---------|-------|
|
|
46
|
+
| `entries` | yes | -- | Map of entry name to `{ input, namespace, out? }`. Must be non-empty. |
|
|
47
|
+
| `entries.*.input` | yes | -- | Path to the TypeScript source file. |
|
|
48
|
+
| `entries.*.namespace` | yes | -- | Global namespace (e.g. `Contoso.Account`) D365 calls handlers through. |
|
|
49
|
+
| `entries.*.out` | no | `<key>.js` | Output filename relative to `outDir`. |
|
|
50
|
+
| `outDir` | no | `./dist` | Output directory for bundles. |
|
|
51
|
+
| `target` | no | `es2020` | JavaScript target version. |
|
|
52
|
+
| `sourcemap` | no | `true` | Emit source maps. |
|
|
53
|
+
| `minify` | no | `false` | Minify output. |
|
|
54
|
+
| `bundler` | no | `esbuild` | Currently only `esbuild`. |
|
|
55
|
+
| `external` | no | `[]` | Modules to exclude from the bundle. |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Via the CLI (recommended)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
xrmforge build # build all entries (parallel)
|
|
63
|
+
xrmforge build --watch # watch mode, ~10ms incremental rebuilds
|
|
64
|
+
xrmforge build --minify
|
|
65
|
+
xrmforge build --no-sourcemap
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
See [`@xrmforge/cli`](https://www.npmjs.com/package/@xrmforge/cli).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Programmatic API
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import {
|
|
76
|
+
build,
|
|
77
|
+
watch,
|
|
78
|
+
validateBuildConfig,
|
|
79
|
+
resolveBuildConfig,
|
|
80
|
+
scaffoldProject,
|
|
81
|
+
BuildError,
|
|
82
|
+
BuildErrorCode,
|
|
83
|
+
} from '@xrmforge/devkit';
|
|
84
|
+
|
|
85
|
+
// Validate + resolve a raw config (throws BuildError with CONFIG_INVALID on bad input)
|
|
86
|
+
const config = resolveBuildConfig(validateBuildConfig(rawConfigFromJson));
|
|
87
|
+
|
|
88
|
+
// One-shot build -> BuildResult (per-entry output paths, sizes, timings)
|
|
89
|
+
const result = await build(config);
|
|
90
|
+
|
|
91
|
+
// Watch mode for incremental rebuilds; returns a disposer to stop watching
|
|
92
|
+
const { dispose } = await watch(config, { onRebuild: (r) => console.log('rebuilt', r) });
|
|
93
|
+
// ... later:
|
|
94
|
+
await dispose();
|
|
95
|
+
|
|
96
|
+
// Scaffold a new project (the engine behind `xrmforge init`)
|
|
97
|
+
await scaffoldProject(/* ScaffoldConfig */);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Export | Purpose |
|
|
101
|
+
|--------|---------|
|
|
102
|
+
| `build(config, cwd?)` | Run all entries once. Returns `BuildResult`. |
|
|
103
|
+
| `watch(config, options?)` | Start watch mode (`onRebuild` callback). Returns `{ dispose }` to stop. |
|
|
104
|
+
| `validateBuildConfig(raw)` | Validate an untyped config; throws `BuildError` on failure. |
|
|
105
|
+
| `resolveBuildConfig(config)` | Apply defaults, returning a `ResolvedBuildConfig`. |
|
|
106
|
+
| `scaffoldProject(config)` | Generate a new project structure (`xrmforge init`). |
|
|
107
|
+
| `BuildError`, `BuildErrorCode` | Structured build errors. |
|
|
108
|
+
|
|
109
|
+
Exported types: `BuildConfig`, `BuildEntry`, `ResolvedBuildConfig`, `BuildResult`, `BuildResultEntry`, `ScaffoldConfig`, `ScaffoldResult`.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Why IIFE?
|
|
114
|
+
|
|
115
|
+
D365 loads Web Resources via `<script>` tags and calls handlers by name (e.g. `Contoso.AccountForm.onLoad`). The IIFE format wraps your module and exposes its exports under the configured global namespace, so the form event system can reach them.
|
|
116
|
+
|
|
117
|
+
## Documentation
|
|
118
|
+
|
|
119
|
+
Full guide -- building, shared libraries, deployment: [XrmForge on GitHub](https://github.com/juergenbeck/XrmForge#readme).
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
[MIT](https://github.com/juergenbeck/XrmForge/blob/main/LICENSE) (c) XrmForge Contributors.
|
package/dist/index.js
CHANGED
|
@@ -310,13 +310,16 @@ function generatePackageJson(projectName) {
|
|
|
310
310
|
// 0.7.0) plus the ./.env auto-load and interactive prompt (0.8.0) need cli at
|
|
311
311
|
// that minor; a 0.x caret never crosses a minor boundary, so an older pin
|
|
312
312
|
// would hand fresh projects a cli without these features.
|
|
313
|
-
// helpers ^0.
|
|
314
|
-
//
|
|
315
|
-
//
|
|
313
|
+
// helpers ^0.10.0: MultiSelect/submit/app-notification helpers (parseMultiSelect,
|
|
314
|
+
// clearAndSubmit, setUnsafeAndSubmit, addAppNotification) ship in 0.10.0 (F-MAR7-03,
|
|
315
|
+
// F-LMA7-07/09); void Custom API executors since 0.9.0 (F-MAR7-01); isFormType since 0.8.0.
|
|
316
|
+
// testing ^0.4.0: complex-form mocks (createFormMock formType option, attribute
|
|
317
|
+
// getText/getPrecision, entity addOnSave/fireOnSave, roles ItemCollection,
|
|
318
|
+
// utilityOverrides) ship in 0.4.0 (F-MAR7-02); the tabs option shipped in 0.3.0.
|
|
316
319
|
"@xrmforge/cli": "^0.8.0",
|
|
317
320
|
"@xrmforge/eslint-plugin": "^0.3.0",
|
|
318
|
-
"@xrmforge/helpers": "^0.
|
|
319
|
-
"@xrmforge/testing": "^0.
|
|
321
|
+
"@xrmforge/helpers": "^0.10.0",
|
|
322
|
+
"@xrmforge/testing": "^0.4.0",
|
|
320
323
|
eslint: "^9.0.0",
|
|
321
324
|
typescript: "^5.7.0",
|
|
322
325
|
vitest: "^3.0.0"
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/errors.ts","../src/config.ts","../src/builder/esbuild-builder.ts","../src/scaffold/scaffold.ts","../src/scaffold/template-loader.ts"],"sourcesContent":["/**\r\n * @xrmforge/devkit - Build Error Types\r\n *\r\n * Structured error types for build operations.\r\n */\r\n\r\nexport enum BuildErrorCode {\r\n /** Build configuration is invalid or missing required fields */\r\n CONFIG_INVALID = 'BUILD_6001',\r\n /** Entry point file not found on disk */\r\n ENTRY_NOT_FOUND = 'BUILD_6002',\r\n /** esbuild compilation failed (syntax errors, missing imports) */\r\n BUILD_FAILED = 'BUILD_6003',\r\n /** Error in watch mode */\r\n WATCH_ERROR = 'BUILD_6004',\r\n}\r\n\r\n/**\r\n * Structured error class for build operations.\r\n *\r\n * Carries a machine-readable {@link BuildErrorCode} and optional context\r\n * for debugging. The error message is prefixed with the code (e.g. `[BUILD_6001]`).\r\n *\r\n * @example\r\n * ```typescript\r\n * throw new BuildError(\r\n * BuildErrorCode.ENTRY_NOT_FOUND,\r\n * 'Could not find entry point: ./src/missing.ts',\r\n * { entry: 'my_script' },\r\n * );\r\n * ```\r\n */\r\nexport class BuildError extends Error {\r\n /** Machine-readable error code for programmatic handling. */\r\n public readonly code: BuildErrorCode;\r\n /** Additional context for debugging (e.g. entry name, file path). */\r\n public readonly context: Record<string, unknown>;\r\n\r\n /**\r\n * @param code - Machine-readable error code\r\n * @param message - Human-readable error description\r\n * @param context - Optional key-value pairs for debugging context\r\n */\r\n constructor(code: BuildErrorCode, message: string, context: Record<string, unknown> = {}) {\r\n super(`[${code}] ${message}`);\r\n this.name = 'BuildError';\r\n this.code = code;\r\n this.context = context;\r\n\r\n if (Error.captureStackTrace) {\r\n Error.captureStackTrace(this, BuildError);\r\n }\r\n }\r\n}\r\n","/**\r\n * @xrmforge/devkit - Build Configuration\r\n *\r\n * Types and validation for the `build` section in xrmforge.config.json.\r\n */\r\n\r\nimport { BuildError, BuildErrorCode } from './errors.js';\r\n\r\n/** A single build entry (one WebResource) */\r\nexport interface BuildEntry {\r\n /** Relative path to the TypeScript source file */\r\n input: string;\r\n /** Global namespace for D365 form event binding (e.g. \"Contoso.Account\") */\r\n namespace: string;\r\n /** Optional output filename relative to outDir (defaults to entry key + \".js\") */\r\n out?: string;\r\n}\r\n\r\n/** Build configuration for WebResource bundling */\r\nexport interface BuildConfig {\r\n /** Bundler to use (currently only \"esbuild\") */\r\n bundler?: 'esbuild';\r\n /** Named build entries: key = entry name, value = entry config */\r\n entries: Record<string, BuildEntry>;\r\n /** Output directory for built bundles (default: \"./dist\") */\r\n outDir?: string;\r\n /** JavaScript target version (default: \"es2020\") */\r\n target?: string;\r\n /** Generate source maps (default: true) */\r\n sourcemap?: boolean;\r\n /** Minify output (default: false) */\r\n minify?: boolean;\r\n /** Additional modules to exclude from bundling */\r\n external?: string[];\r\n}\r\n\r\n/** Fully resolved build config with all defaults applied */\r\nexport interface ResolvedBuildConfig {\r\n bundler: 'esbuild';\r\n entries: Record<string, BuildEntry>;\r\n outDir: string;\r\n target: string;\r\n sourcemap: boolean;\r\n minify: boolean;\r\n external: string[];\r\n}\r\n\r\n/**\r\n * Validate a raw build config object parsed from xrmforge.config.json.\r\n *\r\n * Checks that all required fields (entries, input, namespace) are present\r\n * and have valid types. Throws {@link BuildError} with CONFIG_INVALID if\r\n * any validation check fails.\r\n *\r\n * @param raw - Untyped config object to validate\r\n * @returns The validated build configuration\r\n * @throws {BuildError} If any required field is missing or has an invalid type\r\n */\r\nexport function validateBuildConfig(raw: unknown): BuildConfig {\r\n if (!raw || typeof raw !== 'object') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration must be an object.',\r\n );\r\n }\r\n\r\n const config = raw as Record<string, unknown>;\r\n\r\n // entries: required, non-empty object\r\n if (!config['entries'] || typeof config['entries'] !== 'object' || Array.isArray(config['entries'])) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration requires an \"entries\" object with at least one entry.',\r\n );\r\n }\r\n\r\n const entries = config['entries'] as Record<string, unknown>;\r\n const entryNames = Object.keys(entries);\r\n\r\n if (entryNames.length === 0) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration requires at least one entry in \"entries\".',\r\n );\r\n }\r\n\r\n for (const name of entryNames) {\r\n const entry = entries[name] as Record<string, unknown> | undefined;\r\n\r\n if (!entry || typeof entry !== 'object') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" must be an object with \"input\" and \"namespace\".`,\r\n { entry: name },\r\n );\r\n }\r\n\r\n if (!entry['input'] || typeof entry['input'] !== 'string') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" requires an \"input\" field (path to .ts source file).`,\r\n { entry: name },\r\n );\r\n }\r\n\r\n if (!entry['namespace'] || typeof entry['namespace'] !== 'string') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" requires a \"namespace\" field (e.g. \"Contoso.Account\").`,\r\n { entry: name },\r\n );\r\n }\r\n }\r\n\r\n // bundler: optional, must be \"esbuild\" if set\r\n if (config['bundler'] !== undefined && config['bundler'] !== 'esbuild') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Unsupported bundler: \"${String(config['bundler'])}\". Currently only \"esbuild\" is supported.`,\r\n { bundler: config['bundler'] },\r\n );\r\n }\r\n\r\n return config as unknown as BuildConfig;\r\n}\r\n\r\n/**\r\n * Apply default values to a validated build config.\r\n *\r\n * Fills in defaults for optional fields: bundler ('esbuild'), outDir ('./dist'),\r\n * target ('es2020'), sourcemap (true), minify (false), external ([]).\r\n *\r\n * @param config - Validated build configuration\r\n * @returns Fully resolved configuration with all defaults applied\r\n */\r\nexport function resolveBuildConfig(config: BuildConfig): ResolvedBuildConfig {\r\n return {\r\n bundler: config.bundler ?? 'esbuild',\r\n entries: config.entries,\r\n outDir: config.outDir ?? './dist',\r\n target: config.target ?? 'es2020',\r\n sourcemap: config.sourcemap ?? true,\r\n minify: config.minify ?? false,\r\n external: config.external ?? [],\r\n };\r\n}\r\n","/**\r\n * @xrmforge/devkit - esbuild Builder\r\n *\r\n * Builds D365 WebResources as IIFE bundles with named globals.\r\n * Abstracts esbuild so users never write esbuild config.\r\n */\r\n\r\nimport * as esbuild from 'esbuild';\r\nimport { stat, mkdir } from 'node:fs/promises';\r\nimport { resolve, dirname } from 'node:path';\r\nimport type { BuildConfig } from '../config.js';\r\nimport { resolveBuildConfig } from '../config.js';\r\nimport { BuildError, BuildErrorCode } from '../errors.js';\r\nimport type { BuildResult, BuildResultEntry } from './types.js';\r\n\r\n/**\r\n * Build all entries defined in the config as IIFE bundles.\r\n *\r\n * @param config - Validated build configuration\r\n * @param cwd - Working directory for resolving relative paths (defaults to process.cwd())\r\n * @returns Build result with per-entry details\r\n */\r\nexport async function build(config: BuildConfig, cwd?: string): Promise<BuildResult> {\r\n const startTime = Date.now();\r\n const resolved = resolveBuildConfig(config);\r\n const basedir = cwd ?? process.cwd();\r\n const outDir = resolve(basedir, resolved.outDir);\r\n\r\n // Ensure output directory exists\r\n await mkdir(outDir, { recursive: true });\r\n\r\n const entryNames = Object.keys(resolved.entries);\r\n const results: BuildResultEntry[] = [];\r\n const errors: string[] = [];\r\n const warnings: string[] = [];\r\n\r\n // Build all entries in parallel\r\n const settled = await Promise.allSettled(\r\n entryNames.map(async (name) => {\r\n const entry = resolved.entries[name]!;\r\n const entryStart = Date.now();\r\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\r\n\r\n // Ensure subdirectory exists for custom out paths\r\n await mkdir(dirname(outFile), { recursive: true });\r\n\r\n const buildOptions: esbuild.BuildOptions = {\r\n entryPoints: [resolve(basedir, entry.input)],\r\n bundle: true,\r\n format: 'iife',\r\n globalName: entry.namespace,\r\n outfile: outFile,\r\n target: [resolved.target],\r\n minify: resolved.minify,\r\n sourcemap: resolved.sourcemap,\r\n treeShaking: true,\r\n logLevel: 'silent',\r\n external: resolved.external,\r\n };\r\n\r\n const result = await esbuild.build(buildOptions);\r\n\r\n // Collect esbuild warnings\r\n for (const w of result.warnings) {\r\n warnings.push(`[${name}] ${w.text}`);\r\n }\r\n\r\n // Get output file size\r\n const stats = await stat(outFile);\r\n\r\n return {\r\n name,\r\n outFile,\r\n sizeBytes: stats.size,\r\n durationMs: Date.now() - entryStart,\r\n } satisfies BuildResultEntry;\r\n }),\r\n );\r\n\r\n for (let i = 0; i < settled.length; i++) {\r\n const outcome = settled[i]!;\r\n const name = entryNames[i]!;\r\n\r\n if (outcome.status === 'fulfilled') {\r\n results.push(outcome.value);\r\n } else {\r\n const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);\r\n // Distinguish \"file not found\" from other build errors\r\n if (errorMsg.includes('Could not resolve') || errorMsg.includes('ENOENT')) {\r\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.ENTRY_NOT_FOUND, errorMsg, { entry: name }).message}`);\r\n } else {\r\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.BUILD_FAILED, errorMsg, { entry: name }).message}`);\r\n }\r\n }\r\n }\r\n\r\n return {\r\n entries: results,\r\n totalDurationMs: Date.now() - startTime,\r\n errors,\r\n warnings,\r\n };\r\n}\r\n\r\n/**\r\n * Start watch mode for all entries.\r\n * Returns a dispose function to stop watching.\r\n *\r\n * @param config - Validated build configuration\r\n * @param options - Watch options\r\n * @returns Object with dispose() to stop watching\r\n */\r\nexport async function watch(\r\n config: BuildConfig,\r\n options?: {\r\n cwd?: string;\r\n onRebuild?: (result: BuildResult) => void;\r\n },\r\n): Promise<{ dispose: () => Promise<void> }> {\r\n const resolved = resolveBuildConfig(config);\r\n const basedir = options?.cwd ?? process.cwd();\r\n const outDir = resolve(basedir, resolved.outDir);\r\n\r\n await mkdir(outDir, { recursive: true });\r\n\r\n const contexts: esbuild.BuildContext[] = [];\r\n\r\n for (const [name, entry] of Object.entries(resolved.entries)) {\r\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\r\n await mkdir(dirname(outFile), { recursive: true });\r\n\r\n const ctx = await esbuild.context({\r\n entryPoints: [resolve(basedir, entry.input)],\r\n bundle: true,\r\n format: 'iife',\r\n globalName: entry.namespace,\r\n outfile: outFile,\r\n target: [resolved.target],\r\n minify: resolved.minify,\r\n sourcemap: resolved.sourcemap,\r\n treeShaking: true,\r\n logLevel: 'silent',\r\n external: resolved.external,\r\n });\r\n\r\n contexts.push(ctx);\r\n await ctx.watch();\r\n }\r\n\r\n return {\r\n dispose: async () => {\r\n for (const ctx of contexts) {\r\n await ctx.dispose();\r\n }\r\n },\r\n };\r\n}\r\n","/**\r\n * @xrmforge/devkit - Project Scaffolding\r\n *\r\n * Generates a complete D365 form scripting project from templates.\r\n */\r\n\r\nimport { mkdir, writeFile, readdir, access } from 'node:fs/promises';\r\nimport { join } from 'node:path';\r\nimport type { ScaffoldConfig, ScaffoldResult } from './types.js';\r\nimport { BuildError, BuildErrorCode } from '../errors.js';\r\nimport { loadTemplate } from './template-loader.js';\r\n\r\n/**\r\n * Scaffold a new D365 form scripting project.\r\n *\r\n * Creates a complete project structure with package.json, tsconfig,\r\n * xrmforge.config.json, example form script, and test file.\r\n *\r\n * @param config - Scaffold configuration\r\n * @returns List of created files and any warnings\r\n * @throws {BuildError} if target directory is not empty (unless files are only dotfiles)\r\n */\r\nexport async function scaffoldProject(config: ScaffoldConfig): Promise<ScaffoldResult> {\r\n const { targetDir } = config;\r\n const filesCreated: string[] = [];\r\n const warnings: string[] = [];\r\n\r\n // Ensure target directory exists\r\n await mkdir(targetDir, { recursive: true });\r\n\r\n // Check if directory is empty (ignore dotfiles and node_modules)\r\n const existing = await readdir(targetDir);\r\n const nonDotFiles = existing.filter((f) => !f.startsWith('.') && f !== 'node_modules');\r\n if (nonDotFiles.length > 0 && !config.force) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Target directory is not empty: ${targetDir}\\n` +\r\n `Found: ${nonDotFiles.slice(0, 5).join(', ')}${nonDotFiles.length > 5 ? '...' : ''}\\n` +\r\n `Use --force to scaffold anyway (existing files will be skipped).`,\r\n { targetDir, existingFiles: nonDotFiles },\r\n );\r\n }\r\n\r\n // Create directory structure\r\n const dirs = [\r\n 'src/forms',\r\n 'src/shared',\r\n 'generated',\r\n 'tests/forms',\r\n ];\r\n\r\n for (const dir of dirs) {\r\n await mkdir(join(targetDir, dir), { recursive: true });\r\n }\r\n\r\n // Generate and write all template files\r\n const templates = await generateTemplates(config);\r\n\r\n for (const [relativePath, content] of templates) {\r\n const absolutePath = join(targetDir, relativePath);\r\n await mkdir(join(absolutePath, '..'), { recursive: true });\r\n\r\n // In force mode: skip files that already exist\r\n if (config.force) {\r\n try {\r\n await access(absolutePath);\r\n warnings.push(`Skipped ${relativePath} (already exists)`);\r\n continue;\r\n } catch {\r\n // File doesn't exist, proceed with write\r\n }\r\n }\r\n\r\n await writeFile(absolutePath, content, 'utf-8');\r\n filesCreated.push(relativePath);\r\n }\r\n\r\n return { filesCreated, warnings };\r\n}\r\n\r\n/**\r\n * Generate all template file contents for a scaffolded project.\r\n *\r\n * @param config - Scaffold configuration with project name, prefix, and namespace\r\n * @returns Array of [relativePath, content] tuples for each file to create\r\n */\r\nasync function generateTemplates(config: ScaffoldConfig): Promise<Array<[string, string]>> {\r\n const { projectName, prefix, namespace } = config;\r\n const lowerPrefix = prefix.toLowerCase();\r\n const namespaceVars = { namespace };\r\n\r\n return [\r\n ['package.json', generatePackageJson(projectName)],\r\n ['tsconfig.json', generateTsConfig()],\r\n ['xrmforge.config.json', generateXrmForgeConfig(lowerPrefix, namespace)],\r\n ['vitest.config.ts', await loadTemplate('vitest.config.ts')],\r\n ['.gitignore', await loadTemplate('gitignore')],\r\n ['.gitattributes', generateGitAttributes()],\r\n ['AGENT.md', await loadTemplate('AGENT.md')],\r\n ['src/forms/example-form.ts', await loadTemplate('example-form.ts', namespaceVars)],\r\n ['generated/.gitkeep', ''],\r\n ['tests/forms/example-form.test.ts', await loadTemplate('example-form.test.ts', namespaceVars)],\r\n ['src/shared/logger.ts', await loadTemplate('logger.ts', namespaceVars)],\r\n ['src/shared/error-handler.ts', await loadTemplate('error-handler.ts')],\r\n ['src/shared/constants.ts', await loadTemplate('constants.ts', namespaceVars)],\r\n ['eslint.config.js', await loadTemplate('eslint.config.js')],\r\n ['.github/workflows/ci.yml', await loadTemplate('github-actions-ci.yml')],\r\n ['azure-pipelines.yml', await loadTemplate('azure-pipelines.yml')],\r\n ['scripts/validate-form.mjs', await loadTemplate('validate-form.mjs')],\r\n ];\r\n}\r\n\r\n/**\r\n * Generate package.json content for a scaffolded project.\r\n *\r\n * @param projectName - The project name for the name field\r\n * @returns Formatted JSON string\r\n */\r\nfunction generatePackageJson(projectName: string): string {\r\n const pkg = {\r\n name: projectName,\r\n version: '0.1.0',\r\n private: true,\r\n type: 'module',\r\n scripts: {\r\n generate: 'xrmforge generate',\r\n typecheck: 'tsc --noEmit',\r\n build: 'xrmforge build',\r\n watch: 'xrmforge build --watch',\r\n test: 'vitest run',\r\n 'test:watch': 'vitest',\r\n validate: 'node scripts/validate-form.mjs',\r\n },\r\n devDependencies: {\r\n '@types/xrm': '^9.0.90',\r\n '@typescript-eslint/eslint-plugin': '^8.0.0',\r\n '@typescript-eslint/parser': '^8.0.0',\r\n // 0.x caret ranges only allow the same minor: keep these pins on the\r\n // current minor of each package, otherwise scaffolded projects install\r\n // outdated versions (e.g. helpers ^0.3.0 never resolves to 0.6.x).\r\n // cli ^0.8.0: the env-var CI template (XRMFORGE_* without auth flags, since\r\n // 0.7.0) plus the ./.env auto-load and interactive prompt (0.8.0) need cli at\r\n // that minor; a 0.x caret never crosses a minor boundary, so an older pin\r\n // would hand fresh projects a cli without these features.\r\n // helpers ^0.8.0: isFormType (the AGENT.md form-type guard) ships in 0.8.0.\r\n // testing ^0.3.0: the createFormMock tabs option (cross-tab section tests)\r\n // ships in 0.3.0.\r\n '@xrmforge/cli': '^0.8.0',\r\n '@xrmforge/eslint-plugin': '^0.3.0',\r\n '@xrmforge/helpers': '^0.8.0',\r\n '@xrmforge/testing': '^0.3.0',\r\n eslint: '^9.0.0',\r\n typescript: '^5.7.0',\r\n vitest: '^3.0.0',\r\n },\r\n };\r\n return JSON.stringify(pkg, null, 2) + '\\n';\r\n}\r\n\r\n/**\r\n * Generate .gitattributes content for a scaffolded project.\r\n *\r\n * Pins generated declarations (and source/config) to LF. typegen writes LF,\r\n * but git with core.autocrlf=true (the Windows default) would otherwise check\r\n * the files out as CRLF, and `xrmforge generate --check` would report false\r\n * drift on every file. Forcing eol=lf keeps the drift gate green on Windows.\r\n *\r\n * @returns .gitattributes content\r\n */\r\nfunction generateGitAttributes(): string {\r\n return [\r\n '# typegen writes LF. Pin generated files to LF so `xrmforge generate --check`',\r\n '# stays stable on Windows (git core.autocrlf would otherwise serve CRLF',\r\n '# working copies and the byte comparison would report false drift).',\r\n 'generated/** text eol=lf',\r\n '',\r\n '# Keep source and config line endings consistent across platforms.',\r\n '*.ts text eol=lf',\r\n '*.mjs text eol=lf',\r\n '*.json text eol=lf',\r\n '',\r\n ].join('\\n');\r\n}\r\n\r\n/**\r\n * Generate tsconfig.json content for a scaffolded project.\r\n *\r\n * @returns Formatted JSON string with D365-appropriate compiler options\r\n */\r\nfunction generateTsConfig(): string {\r\n const config = {\r\n compilerOptions: {\r\n target: 'ES2020',\r\n module: 'ESNext',\r\n moduleResolution: 'bundler',\r\n lib: ['ES2020', 'DOM'],\r\n types: ['xrm'],\r\n strict: true,\r\n noEmit: true,\r\n skipLibCheck: false,\r\n esModuleInterop: true,\r\n },\r\n include: [\r\n 'src/**/*.ts',\r\n 'generated/**/*.ts',\r\n ],\r\n };\r\n return JSON.stringify(config, null, 2) + '\\n';\r\n}\r\n\r\n/**\r\n * Generate xrmforge.config.json content for a scaffolded project.\r\n *\r\n * @param prefix - Publisher prefix for WebResource paths (lowercase)\r\n * @param namespace - Base namespace for form script globals\r\n * @returns Formatted JSON string with a sample build entry\r\n */\r\nfunction generateXrmForgeConfig(prefix: string, namespace: string): string {\r\n const config = {\r\n build: {\r\n outDir: `./dist/${prefix}_/JS`,\r\n target: 'es2020',\r\n sourcemap: true,\r\n minify: true,\r\n entries: {\r\n example_form: {\r\n input: './src/forms/example-form.ts',\r\n namespace: `${namespace}.Example`,\r\n out: 'Example/OnLoad.js',\r\n },\r\n },\r\n },\r\n };\r\n return JSON.stringify(config, null, 2) + '\\n';\r\n}\r\n","/**\r\n * Template loader for scaffold templates.\r\n *\r\n * Reads template files from the templates/ directory relative to this module.\r\n * Supports {{placeholder}} variable substitution.\r\n */\r\n\r\nimport { readFile } from 'node:fs/promises';\r\nimport { dirname, join } from 'node:path';\r\nimport { fileURLToPath } from 'node:url';\r\n\r\nconst __dirname = dirname(fileURLToPath(import.meta.url));\r\n\r\n/** Path to the templates directory (relative to compiled output or source). */\r\nconst TEMPLATES_DIR = join(__dirname, 'templates');\r\n\r\n/**\r\n * Load a template file by name and optionally substitute variables.\r\n *\r\n * Variables in the template use the `{{key}}` syntax.\r\n *\r\n * @param name - Template filename (e.g. 'AGENT.md', 'example-form.ts')\r\n * @param vars - Optional key-value pairs for placeholder substitution\r\n * @returns Template content with variables replaced\r\n */\r\nexport async function loadTemplate(\r\n name: string,\r\n vars?: Record<string, string>,\r\n): Promise<string> {\r\n const content = await readFile(join(TEMPLATES_DIR, name), 'utf-8');\r\n\r\n if (!vars || Object.keys(vars).length === 0) {\r\n return content;\r\n }\r\n\r\n return Object.entries(vars).reduce(\r\n (result, [key, value]) => result.replaceAll(`{{${key}}}`, value),\r\n content,\r\n );\r\n}\r\n"],"mappings":";AAMO,IAAK,iBAAL,kBAAKA,oBAAL;AAEL,EAAAA,gBAAA,oBAAiB;AAEjB,EAAAA,gBAAA,qBAAkB;AAElB,EAAAA,gBAAA,kBAAe;AAEf,EAAAA,gBAAA,iBAAc;AARJ,SAAAA;AAAA,GAAA;AA0BL,IAAM,aAAN,MAAM,oBAAmB,MAAM;AAAA;AAAA,EAEpB;AAAA;AAAA,EAEA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOhB,YAAY,MAAsB,SAAiBC,WAAmC,CAAC,GAAG;AACxF,UAAM,IAAI,IAAI,KAAK,OAAO,EAAE;AAC5B,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,UAAUA;AAEf,QAAI,MAAM,mBAAmB;AAC3B,YAAM,kBAAkB,MAAM,WAAU;AAAA,IAC1C;AAAA,EACF;AACF;;;ACKO,SAAS,oBAAoB,KAA2B;AAC7D,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,UAAM,IAAI;AAAA;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAEA,QAAM,SAAS;AAGf,MAAI,CAAC,OAAO,SAAS,KAAK,OAAO,OAAO,SAAS,MAAM,YAAY,MAAM,QAAQ,OAAO,SAAS,CAAC,GAAG;AACnG,UAAM,IAAI;AAAA;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAEA,QAAM,UAAU,OAAO,SAAS;AAChC,QAAM,aAAa,OAAO,KAAK,OAAO;AAEtC,MAAI,WAAW,WAAW,GAAG;AAC3B,UAAM,IAAI;AAAA;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAEA,aAAW,QAAQ,YAAY;AAC7B,UAAM,QAAQ,QAAQ,IAAI;AAE1B,QAAI,CAAC,SAAS,OAAO,UAAU,UAAU;AACvC,YAAM,IAAI;AAAA;AAAA,QAER,UAAU,IAAI;AAAA,QACd,EAAE,OAAO,KAAK;AAAA,MAChB;AAAA,IACF;AAEA,QAAI,CAAC,MAAM,OAAO,KAAK,OAAO,MAAM,OAAO,MAAM,UAAU;AACzD,YAAM,IAAI;AAAA;AAAA,QAER,UAAU,IAAI;AAAA,QACd,EAAE,OAAO,KAAK;AAAA,MAChB;AAAA,IACF;AAEA,QAAI,CAAC,MAAM,WAAW,KAAK,OAAO,MAAM,WAAW,MAAM,UAAU;AACjE,YAAM,IAAI;AAAA;AAAA,QAER,UAAU,IAAI;AAAA,QACd,EAAE,OAAO,KAAK;AAAA,MAChB;AAAA,IACF;AAAA,EACF;AAGA,MAAI,OAAO,SAAS,MAAM,UAAa,OAAO,SAAS,MAAM,WAAW;AACtE,UAAM,IAAI;AAAA;AAAA,MAER,yBAAyB,OAAO,OAAO,SAAS,CAAC,CAAC;AAAA,MAClD,EAAE,SAAS,OAAO,SAAS,EAAE;AAAA,IAC/B;AAAA,EACF;AAEA,SAAO;AACT;AAWO,SAAS,mBAAmB,QAA0C;AAC3E,SAAO;AAAA,IACL,SAAS,OAAO,WAAW;AAAA,IAC3B,SAAS,OAAO;AAAA,IAChB,QAAQ,OAAO,UAAU;AAAA,IACzB,QAAQ,OAAO,UAAU;AAAA,IACzB,WAAW,OAAO,aAAa;AAAA,IAC/B,QAAQ,OAAO,UAAU;AAAA,IACzB,UAAU,OAAO,YAAY,CAAC;AAAA,EAChC;AACF;;;AC1IA,YAAY,aAAa;AACzB,SAAS,MAAM,aAAa;AAC5B,SAAS,SAAS,eAAe;AAajC,eAAsBC,OAAM,QAAqB,KAAoC;AACnF,QAAM,YAAY,KAAK,IAAI;AAC3B,QAAM,WAAW,mBAAmB,MAAM;AAC1C,QAAM,UAAU,OAAO,QAAQ,IAAI;AACnC,QAAM,SAAS,QAAQ,SAAS,SAAS,MAAM;AAG/C,QAAM,MAAM,QAAQ,EAAE,WAAW,KAAK,CAAC;AAEvC,QAAM,aAAa,OAAO,KAAK,SAAS,OAAO;AAC/C,QAAM,UAA8B,CAAC;AACrC,QAAM,SAAmB,CAAC;AAC1B,QAAM,WAAqB,CAAC;AAG5B,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC5B,WAAW,IAAI,OAAO,SAAS;AAC7B,YAAM,QAAQ,SAAS,QAAQ,IAAI;AACnC,YAAM,aAAa,KAAK,IAAI;AAC5B,YAAM,UAAU,QAAQ,QAAQ,MAAM,OAAO,GAAG,IAAI,KAAK;AAGzD,YAAM,MAAM,QAAQ,OAAO,GAAG,EAAE,WAAW,KAAK,CAAC;AAEjD,YAAM,eAAqC;AAAA,QACzC,aAAa,CAAC,QAAQ,SAAS,MAAM,KAAK,CAAC;AAAA,QAC3C,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,YAAY,MAAM;AAAA,QAClB,SAAS;AAAA,QACT,QAAQ,CAAC,SAAS,MAAM;AAAA,QACxB,QAAQ,SAAS;AAAA,QACjB,WAAW,SAAS;AAAA,QACpB,aAAa;AAAA,QACb,UAAU;AAAA,QACV,UAAU,SAAS;AAAA,MACrB;AAEA,YAAM,SAAS,MAAc,cAAM,YAAY;AAG/C,iBAAW,KAAK,OAAO,UAAU;AAC/B,iBAAS,KAAK,IAAI,IAAI,KAAK,EAAE,IAAI,EAAE;AAAA,MACrC;AAGA,YAAM,QAAQ,MAAM,KAAK,OAAO;AAEhC,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA,WAAW,MAAM;AAAA,QACjB,YAAY,KAAK,IAAI,IAAI;AAAA,MAC3B;AAAA,IACF,CAAC;AAAA,EACH;AAEA,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,UAAM,UAAU,QAAQ,CAAC;AACzB,UAAM,OAAO,WAAW,CAAC;AAEzB,QAAI,QAAQ,WAAW,aAAa;AAClC,cAAQ,KAAK,QAAQ,KAAK;AAAA,IAC5B,OAAO;AACL,YAAM,WAAW,QAAQ,kBAAkB,QAAQ,QAAQ,OAAO,UAAU,OAAO,QAAQ,MAAM;AAEjG,UAAI,SAAS,SAAS,mBAAmB,KAAK,SAAS,SAAS,QAAQ,GAAG;AACzE,eAAO,KAAK,IAAI,IAAI,KAAK,IAAI,+CAA2C,UAAU,EAAE,OAAO,KAAK,CAAC,EAAE,OAAO,EAAE;AAAA,MAC9G,OAAO;AACL,eAAO,KAAK,IAAI,IAAI,KAAK,IAAI,4CAAwC,UAAU,EAAE,OAAO,KAAK,CAAC,EAAE,OAAO,EAAE;AAAA,MAC3G;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,SAAS;AAAA,IACT,iBAAiB,KAAK,IAAI,IAAI;AAAA,IAC9B;AAAA,IACA;AAAA,EACF;AACF;AAUA,eAAsB,MACpB,QACA,SAI2C;AAC3C,QAAM,WAAW,mBAAmB,MAAM;AAC1C,QAAM,UAAU,SAAS,OAAO,QAAQ,IAAI;AAC5C,QAAM,SAAS,QAAQ,SAAS,SAAS,MAAM;AAE/C,QAAM,MAAM,QAAQ,EAAE,WAAW,KAAK,CAAC;AAEvC,QAAM,WAAmC,CAAC;AAE1C,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,SAAS,OAAO,GAAG;AAC5D,UAAM,UAAU,QAAQ,QAAQ,MAAM,OAAO,GAAG,IAAI,KAAK;AACzD,UAAM,MAAM,QAAQ,OAAO,GAAG,EAAE,WAAW,KAAK,CAAC;AAEjD,UAAM,MAAM,MAAc,gBAAQ;AAAA,MAChC,aAAa,CAAC,QAAQ,SAAS,MAAM,KAAK,CAAC;AAAA,MAC3C,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,YAAY,MAAM;AAAA,MAClB,SAAS;AAAA,MACT,QAAQ,CAAC,SAAS,MAAM;AAAA,MACxB,QAAQ,SAAS;AAAA,MACjB,WAAW,SAAS;AAAA,MACpB,aAAa;AAAA,MACb,UAAU;AAAA,MACV,UAAU,SAAS;AAAA,IACrB,CAAC;AAED,aAAS,KAAK,GAAG;AACjB,UAAM,IAAI,MAAM;AAAA,EAClB;AAEA,SAAO;AAAA,IACL,SAAS,YAAY;AACnB,iBAAW,OAAO,UAAU;AAC1B,cAAM,IAAI,QAAQ;AAAA,MACpB;AAAA,IACF;AAAA,EACF;AACF;;;ACtJA,SAAS,SAAAC,QAAO,WAAW,SAAS,cAAc;AAClD,SAAS,QAAAC,aAAY;;;ACArB,SAAS,gBAAgB;AACzB,SAAS,WAAAC,UAAS,YAAY;AAC9B,SAAS,qBAAqB;AAE9B,IAAM,YAAYA,SAAQ,cAAc,YAAY,GAAG,CAAC;AAGxD,IAAM,gBAAgB,KAAK,WAAW,WAAW;AAWjD,eAAsB,aACpB,MACA,MACiB;AACjB,QAAM,UAAU,MAAM,SAAS,KAAK,eAAe,IAAI,GAAG,OAAO;AAEjE,MAAI,CAAC,QAAQ,OAAO,KAAK,IAAI,EAAE,WAAW,GAAG;AAC3C,WAAO;AAAA,EACT;AAEA,SAAO,OAAO,QAAQ,IAAI,EAAE;AAAA,IAC1B,CAAC,QAAQ,CAAC,KAAK,KAAK,MAAM,OAAO,WAAW,KAAK,GAAG,MAAM,KAAK;AAAA,IAC/D;AAAA,EACF;AACF;;;ADjBA,eAAsB,gBAAgB,QAAiD;AACrF,QAAM,EAAE,UAAU,IAAI;AACtB,QAAM,eAAyB,CAAC;AAChC,QAAM,WAAqB,CAAC;AAG5B,QAAMC,OAAM,WAAW,EAAE,WAAW,KAAK,CAAC;AAG1C,QAAM,WAAW,MAAM,QAAQ,SAAS;AACxC,QAAM,cAAc,SAAS,OAAO,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,KAAK,MAAM,cAAc;AACrF,MAAI,YAAY,SAAS,KAAK,CAAC,OAAO,OAAO;AAC3C,UAAM,IAAI;AAAA;AAAA,MAER,kCAAkC,SAAS;AAAA,SAC/B,YAAY,MAAM,GAAG,CAAC,EAAE,KAAK,IAAI,CAAC,GAAG,YAAY,SAAS,IAAI,QAAQ,EAAE;AAAA;AAAA,MAEpF,EAAE,WAAW,eAAe,YAAY;AAAA,IAC1C;AAAA,EACF;AAGA,QAAM,OAAO;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,aAAW,OAAO,MAAM;AACtB,UAAMA,OAAMC,MAAK,WAAW,GAAG,GAAG,EAAE,WAAW,KAAK,CAAC;AAAA,EACvD;AAGA,QAAM,YAAY,MAAM,kBAAkB,MAAM;AAEhD,aAAW,CAAC,cAAc,OAAO,KAAK,WAAW;AAC/C,UAAM,eAAeA,MAAK,WAAW,YAAY;AACjD,UAAMD,OAAMC,MAAK,cAAc,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAGzD,QAAI,OAAO,OAAO;AAChB,UAAI;AACF,cAAM,OAAO,YAAY;AACzB,iBAAS,KAAK,WAAW,YAAY,mBAAmB;AACxD;AAAA,MACF,QAAQ;AAAA,MAER;AAAA,IACF;AAEA,UAAM,UAAU,cAAc,SAAS,OAAO;AAC9C,iBAAa,KAAK,YAAY;AAAA,EAChC;AAEA,SAAO,EAAE,cAAc,SAAS;AAClC;AAQA,eAAe,kBAAkB,QAA0D;AACzF,QAAM,EAAE,aAAa,QAAQ,UAAU,IAAI;AAC3C,QAAM,cAAc,OAAO,YAAY;AACvC,QAAM,gBAAgB,EAAE,UAAU;AAElC,SAAO;AAAA,IACL,CAAC,gBAAgB,oBAAoB,WAAW,CAAC;AAAA,IACjD,CAAC,iBAAiB,iBAAiB,CAAC;AAAA,IACpC,CAAC,wBAAwB,uBAAuB,aAAa,SAAS,CAAC;AAAA,IACvE,CAAC,oBAAoB,MAAM,aAAa,kBAAkB,CAAC;AAAA,IAC3D,CAAC,cAAc,MAAM,aAAa,WAAW,CAAC;AAAA,IAC9C,CAAC,kBAAkB,sBAAsB,CAAC;AAAA,IAC1C,CAAC,YAAY,MAAM,aAAa,UAAU,CAAC;AAAA,IAC3C,CAAC,6BAA6B,MAAM,aAAa,mBAAmB,aAAa,CAAC;AAAA,IAClF,CAAC,sBAAsB,EAAE;AAAA,IACzB,CAAC,oCAAoC,MAAM,aAAa,wBAAwB,aAAa,CAAC;AAAA,IAC9F,CAAC,wBAAwB,MAAM,aAAa,aAAa,aAAa,CAAC;AAAA,IACvE,CAAC,+BAA+B,MAAM,aAAa,kBAAkB,CAAC;AAAA,IACtE,CAAC,2BAA2B,MAAM,aAAa,gBAAgB,aAAa,CAAC;AAAA,IAC7E,CAAC,oBAAoB,MAAM,aAAa,kBAAkB,CAAC;AAAA,IAC3D,CAAC,4BAA4B,MAAM,aAAa,uBAAuB,CAAC;AAAA,IACxE,CAAC,uBAAuB,MAAM,aAAa,qBAAqB,CAAC;AAAA,IACjE,CAAC,6BAA6B,MAAM,aAAa,mBAAmB,CAAC;AAAA,EACvE;AACF;AAQA,SAAS,oBAAoB,aAA6B;AACxD,QAAM,MAAM;AAAA,IACV,MAAM;AAAA,IACN,SAAS;AAAA,IACT,SAAS;AAAA,IACT,MAAM;AAAA,IACN,SAAS;AAAA,MACP,UAAU;AAAA,MACV,WAAW;AAAA,MACX,OAAO;AAAA,MACP,OAAO;AAAA,MACP,MAAM;AAAA,MACN,cAAc;AAAA,MACd,UAAU;AAAA,IACZ;AAAA,IACA,iBAAiB;AAAA,MACf,cAAc;AAAA,MACd,oCAAoC;AAAA,MACpC,6BAA6B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAW7B,iBAAiB;AAAA,MACjB,2BAA2B;AAAA,MAC3B,qBAAqB;AAAA,MACrB,qBAAqB;AAAA,MACrB,QAAQ;AAAA,MACR,YAAY;AAAA,MACZ,QAAQ;AAAA,IACV;AAAA,EACF;AACA,SAAO,KAAK,UAAU,KAAK,MAAM,CAAC,IAAI;AACxC;AAYA,SAAS,wBAAgC;AACvC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,IAAI;AACb;AAOA,SAAS,mBAA2B;AAClC,QAAM,SAAS;AAAA,IACb,iBAAiB;AAAA,MACf,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,kBAAkB;AAAA,MAClB,KAAK,CAAC,UAAU,KAAK;AAAA,MACrB,OAAO,CAAC,KAAK;AAAA,MACb,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,cAAc;AAAA,MACd,iBAAiB;AAAA,IACnB;AAAA,IACA,SAAS;AAAA,MACP;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO,KAAK,UAAU,QAAQ,MAAM,CAAC,IAAI;AAC3C;AASA,SAAS,uBAAuB,QAAgB,WAA2B;AACzE,QAAM,SAAS;AAAA,IACb,OAAO;AAAA,MACL,QAAQ,UAAU,MAAM;AAAA,MACxB,QAAQ;AAAA,MACR,WAAW;AAAA,MACX,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,cAAc;AAAA,UACZ,OAAO;AAAA,UACP,WAAW,GAAG,SAAS;AAAA,UACvB,KAAK;AAAA,QACP;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO,KAAK,UAAU,QAAQ,MAAM,CAAC,IAAI;AAC3C;","names":["BuildErrorCode","context","build","mkdir","join","dirname","mkdir","join"]}
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts","../src/config.ts","../src/builder/esbuild-builder.ts","../src/scaffold/scaffold.ts","../src/scaffold/template-loader.ts"],"sourcesContent":["/**\r\n * @xrmforge/devkit - Build Error Types\r\n *\r\n * Structured error types for build operations.\r\n */\r\n\r\nexport enum BuildErrorCode {\r\n /** Build configuration is invalid or missing required fields */\r\n CONFIG_INVALID = 'BUILD_6001',\r\n /** Entry point file not found on disk */\r\n ENTRY_NOT_FOUND = 'BUILD_6002',\r\n /** esbuild compilation failed (syntax errors, missing imports) */\r\n BUILD_FAILED = 'BUILD_6003',\r\n /** Error in watch mode */\r\n WATCH_ERROR = 'BUILD_6004',\r\n}\r\n\r\n/**\r\n * Structured error class for build operations.\r\n *\r\n * Carries a machine-readable {@link BuildErrorCode} and optional context\r\n * for debugging. The error message is prefixed with the code (e.g. `[BUILD_6001]`).\r\n *\r\n * @example\r\n * ```typescript\r\n * throw new BuildError(\r\n * BuildErrorCode.ENTRY_NOT_FOUND,\r\n * 'Could not find entry point: ./src/missing.ts',\r\n * { entry: 'my_script' },\r\n * );\r\n * ```\r\n */\r\nexport class BuildError extends Error {\r\n /** Machine-readable error code for programmatic handling. */\r\n public readonly code: BuildErrorCode;\r\n /** Additional context for debugging (e.g. entry name, file path). */\r\n public readonly context: Record<string, unknown>;\r\n\r\n /**\r\n * @param code - Machine-readable error code\r\n * @param message - Human-readable error description\r\n * @param context - Optional key-value pairs for debugging context\r\n */\r\n constructor(code: BuildErrorCode, message: string, context: Record<string, unknown> = {}) {\r\n super(`[${code}] ${message}`);\r\n this.name = 'BuildError';\r\n this.code = code;\r\n this.context = context;\r\n\r\n if (Error.captureStackTrace) {\r\n Error.captureStackTrace(this, BuildError);\r\n }\r\n }\r\n}\r\n","/**\r\n * @xrmforge/devkit - Build Configuration\r\n *\r\n * Types and validation for the `build` section in xrmforge.config.json.\r\n */\r\n\r\nimport { BuildError, BuildErrorCode } from './errors.js';\r\n\r\n/** A single build entry (one WebResource) */\r\nexport interface BuildEntry {\r\n /** Relative path to the TypeScript source file */\r\n input: string;\r\n /** Global namespace for D365 form event binding (e.g. \"Contoso.Account\") */\r\n namespace: string;\r\n /** Optional output filename relative to outDir (defaults to entry key + \".js\") */\r\n out?: string;\r\n}\r\n\r\n/** Build configuration for WebResource bundling */\r\nexport interface BuildConfig {\r\n /** Bundler to use (currently only \"esbuild\") */\r\n bundler?: 'esbuild';\r\n /** Named build entries: key = entry name, value = entry config */\r\n entries: Record<string, BuildEntry>;\r\n /** Output directory for built bundles (default: \"./dist\") */\r\n outDir?: string;\r\n /** JavaScript target version (default: \"es2020\") */\r\n target?: string;\r\n /** Generate source maps (default: true) */\r\n sourcemap?: boolean;\r\n /** Minify output (default: false) */\r\n minify?: boolean;\r\n /** Additional modules to exclude from bundling */\r\n external?: string[];\r\n}\r\n\r\n/** Fully resolved build config with all defaults applied */\r\nexport interface ResolvedBuildConfig {\r\n bundler: 'esbuild';\r\n entries: Record<string, BuildEntry>;\r\n outDir: string;\r\n target: string;\r\n sourcemap: boolean;\r\n minify: boolean;\r\n external: string[];\r\n}\r\n\r\n/**\r\n * Validate a raw build config object parsed from xrmforge.config.json.\r\n *\r\n * Checks that all required fields (entries, input, namespace) are present\r\n * and have valid types. Throws {@link BuildError} with CONFIG_INVALID if\r\n * any validation check fails.\r\n *\r\n * @param raw - Untyped config object to validate\r\n * @returns The validated build configuration\r\n * @throws {BuildError} If any required field is missing or has an invalid type\r\n */\r\nexport function validateBuildConfig(raw: unknown): BuildConfig {\r\n if (!raw || typeof raw !== 'object') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration must be an object.',\r\n );\r\n }\r\n\r\n const config = raw as Record<string, unknown>;\r\n\r\n // entries: required, non-empty object\r\n if (!config['entries'] || typeof config['entries'] !== 'object' || Array.isArray(config['entries'])) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration requires an \"entries\" object with at least one entry.',\r\n );\r\n }\r\n\r\n const entries = config['entries'] as Record<string, unknown>;\r\n const entryNames = Object.keys(entries);\r\n\r\n if (entryNames.length === 0) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration requires at least one entry in \"entries\".',\r\n );\r\n }\r\n\r\n for (const name of entryNames) {\r\n const entry = entries[name] as Record<string, unknown> | undefined;\r\n\r\n if (!entry || typeof entry !== 'object') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" must be an object with \"input\" and \"namespace\".`,\r\n { entry: name },\r\n );\r\n }\r\n\r\n if (!entry['input'] || typeof entry['input'] !== 'string') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" requires an \"input\" field (path to .ts source file).`,\r\n { entry: name },\r\n );\r\n }\r\n\r\n if (!entry['namespace'] || typeof entry['namespace'] !== 'string') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" requires a \"namespace\" field (e.g. \"Contoso.Account\").`,\r\n { entry: name },\r\n );\r\n }\r\n }\r\n\r\n // bundler: optional, must be \"esbuild\" if set\r\n if (config['bundler'] !== undefined && config['bundler'] !== 'esbuild') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Unsupported bundler: \"${String(config['bundler'])}\". Currently only \"esbuild\" is supported.`,\r\n { bundler: config['bundler'] },\r\n );\r\n }\r\n\r\n return config as unknown as BuildConfig;\r\n}\r\n\r\n/**\r\n * Apply default values to a validated build config.\r\n *\r\n * Fills in defaults for optional fields: bundler ('esbuild'), outDir ('./dist'),\r\n * target ('es2020'), sourcemap (true), minify (false), external ([]).\r\n *\r\n * @param config - Validated build configuration\r\n * @returns Fully resolved configuration with all defaults applied\r\n */\r\nexport function resolveBuildConfig(config: BuildConfig): ResolvedBuildConfig {\r\n return {\r\n bundler: config.bundler ?? 'esbuild',\r\n entries: config.entries,\r\n outDir: config.outDir ?? './dist',\r\n target: config.target ?? 'es2020',\r\n sourcemap: config.sourcemap ?? true,\r\n minify: config.minify ?? false,\r\n external: config.external ?? [],\r\n };\r\n}\r\n","/**\r\n * @xrmforge/devkit - esbuild Builder\r\n *\r\n * Builds D365 WebResources as IIFE bundles with named globals.\r\n * Abstracts esbuild so users never write esbuild config.\r\n */\r\n\r\nimport * as esbuild from 'esbuild';\r\nimport { stat, mkdir } from 'node:fs/promises';\r\nimport { resolve, dirname } from 'node:path';\r\nimport type { BuildConfig } from '../config.js';\r\nimport { resolveBuildConfig } from '../config.js';\r\nimport { BuildError, BuildErrorCode } from '../errors.js';\r\nimport type { BuildResult, BuildResultEntry } from './types.js';\r\n\r\n/**\r\n * Build all entries defined in the config as IIFE bundles.\r\n *\r\n * @param config - Validated build configuration\r\n * @param cwd - Working directory for resolving relative paths (defaults to process.cwd())\r\n * @returns Build result with per-entry details\r\n */\r\nexport async function build(config: BuildConfig, cwd?: string): Promise<BuildResult> {\r\n const startTime = Date.now();\r\n const resolved = resolveBuildConfig(config);\r\n const basedir = cwd ?? process.cwd();\r\n const outDir = resolve(basedir, resolved.outDir);\r\n\r\n // Ensure output directory exists\r\n await mkdir(outDir, { recursive: true });\r\n\r\n const entryNames = Object.keys(resolved.entries);\r\n const results: BuildResultEntry[] = [];\r\n const errors: string[] = [];\r\n const warnings: string[] = [];\r\n\r\n // Build all entries in parallel\r\n const settled = await Promise.allSettled(\r\n entryNames.map(async (name) => {\r\n const entry = resolved.entries[name]!;\r\n const entryStart = Date.now();\r\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\r\n\r\n // Ensure subdirectory exists for custom out paths\r\n await mkdir(dirname(outFile), { recursive: true });\r\n\r\n const buildOptions: esbuild.BuildOptions = {\r\n entryPoints: [resolve(basedir, entry.input)],\r\n bundle: true,\r\n format: 'iife',\r\n globalName: entry.namespace,\r\n outfile: outFile,\r\n target: [resolved.target],\r\n minify: resolved.minify,\r\n sourcemap: resolved.sourcemap,\r\n treeShaking: true,\r\n logLevel: 'silent',\r\n external: resolved.external,\r\n };\r\n\r\n const result = await esbuild.build(buildOptions);\r\n\r\n // Collect esbuild warnings\r\n for (const w of result.warnings) {\r\n warnings.push(`[${name}] ${w.text}`);\r\n }\r\n\r\n // Get output file size\r\n const stats = await stat(outFile);\r\n\r\n return {\r\n name,\r\n outFile,\r\n sizeBytes: stats.size,\r\n durationMs: Date.now() - entryStart,\r\n } satisfies BuildResultEntry;\r\n }),\r\n );\r\n\r\n for (let i = 0; i < settled.length; i++) {\r\n const outcome = settled[i]!;\r\n const name = entryNames[i]!;\r\n\r\n if (outcome.status === 'fulfilled') {\r\n results.push(outcome.value);\r\n } else {\r\n const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);\r\n // Distinguish \"file not found\" from other build errors\r\n if (errorMsg.includes('Could not resolve') || errorMsg.includes('ENOENT')) {\r\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.ENTRY_NOT_FOUND, errorMsg, { entry: name }).message}`);\r\n } else {\r\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.BUILD_FAILED, errorMsg, { entry: name }).message}`);\r\n }\r\n }\r\n }\r\n\r\n return {\r\n entries: results,\r\n totalDurationMs: Date.now() - startTime,\r\n errors,\r\n warnings,\r\n };\r\n}\r\n\r\n/**\r\n * Start watch mode for all entries.\r\n * Returns a dispose function to stop watching.\r\n *\r\n * @param config - Validated build configuration\r\n * @param options - Watch options\r\n * @returns Object with dispose() to stop watching\r\n */\r\nexport async function watch(\r\n config: BuildConfig,\r\n options?: {\r\n cwd?: string;\r\n onRebuild?: (result: BuildResult) => void;\r\n },\r\n): Promise<{ dispose: () => Promise<void> }> {\r\n const resolved = resolveBuildConfig(config);\r\n const basedir = options?.cwd ?? process.cwd();\r\n const outDir = resolve(basedir, resolved.outDir);\r\n\r\n await mkdir(outDir, { recursive: true });\r\n\r\n const contexts: esbuild.BuildContext[] = [];\r\n\r\n for (const [name, entry] of Object.entries(resolved.entries)) {\r\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\r\n await mkdir(dirname(outFile), { recursive: true });\r\n\r\n const ctx = await esbuild.context({\r\n entryPoints: [resolve(basedir, entry.input)],\r\n bundle: true,\r\n format: 'iife',\r\n globalName: entry.namespace,\r\n outfile: outFile,\r\n target: [resolved.target],\r\n minify: resolved.minify,\r\n sourcemap: resolved.sourcemap,\r\n treeShaking: true,\r\n logLevel: 'silent',\r\n external: resolved.external,\r\n });\r\n\r\n contexts.push(ctx);\r\n await ctx.watch();\r\n }\r\n\r\n return {\r\n dispose: async () => {\r\n for (const ctx of contexts) {\r\n await ctx.dispose();\r\n }\r\n },\r\n };\r\n}\r\n","/**\r\n * @xrmforge/devkit - Project Scaffolding\r\n *\r\n * Generates a complete D365 form scripting project from templates.\r\n */\r\n\r\nimport { mkdir, writeFile, readdir, access } from 'node:fs/promises';\r\nimport { join } from 'node:path';\r\nimport type { ScaffoldConfig, ScaffoldResult } from './types.js';\r\nimport { BuildError, BuildErrorCode } from '../errors.js';\r\nimport { loadTemplate } from './template-loader.js';\r\n\r\n/**\r\n * Scaffold a new D365 form scripting project.\r\n *\r\n * Creates a complete project structure with package.json, tsconfig,\r\n * xrmforge.config.json, example form script, and test file.\r\n *\r\n * @param config - Scaffold configuration\r\n * @returns List of created files and any warnings\r\n * @throws {BuildError} if target directory is not empty (unless files are only dotfiles)\r\n */\r\nexport async function scaffoldProject(config: ScaffoldConfig): Promise<ScaffoldResult> {\r\n const { targetDir } = config;\r\n const filesCreated: string[] = [];\r\n const warnings: string[] = [];\r\n\r\n // Ensure target directory exists\r\n await mkdir(targetDir, { recursive: true });\r\n\r\n // Check if directory is empty (ignore dotfiles and node_modules)\r\n const existing = await readdir(targetDir);\r\n const nonDotFiles = existing.filter((f) => !f.startsWith('.') && f !== 'node_modules');\r\n if (nonDotFiles.length > 0 && !config.force) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Target directory is not empty: ${targetDir}\\n` +\r\n `Found: ${nonDotFiles.slice(0, 5).join(', ')}${nonDotFiles.length > 5 ? '...' : ''}\\n` +\r\n `Use --force to scaffold anyway (existing files will be skipped).`,\r\n { targetDir, existingFiles: nonDotFiles },\r\n );\r\n }\r\n\r\n // Create directory structure\r\n const dirs = [\r\n 'src/forms',\r\n 'src/shared',\r\n 'generated',\r\n 'tests/forms',\r\n ];\r\n\r\n for (const dir of dirs) {\r\n await mkdir(join(targetDir, dir), { recursive: true });\r\n }\r\n\r\n // Generate and write all template files\r\n const templates = await generateTemplates(config);\r\n\r\n for (const [relativePath, content] of templates) {\r\n const absolutePath = join(targetDir, relativePath);\r\n await mkdir(join(absolutePath, '..'), { recursive: true });\r\n\r\n // In force mode: skip files that already exist\r\n if (config.force) {\r\n try {\r\n await access(absolutePath);\r\n warnings.push(`Skipped ${relativePath} (already exists)`);\r\n continue;\r\n } catch {\r\n // File doesn't exist, proceed with write\r\n }\r\n }\r\n\r\n await writeFile(absolutePath, content, 'utf-8');\r\n filesCreated.push(relativePath);\r\n }\r\n\r\n return { filesCreated, warnings };\r\n}\r\n\r\n/**\r\n * Generate all template file contents for a scaffolded project.\r\n *\r\n * @param config - Scaffold configuration with project name, prefix, and namespace\r\n * @returns Array of [relativePath, content] tuples for each file to create\r\n */\r\nasync function generateTemplates(config: ScaffoldConfig): Promise<Array<[string, string]>> {\r\n const { projectName, prefix, namespace } = config;\r\n const lowerPrefix = prefix.toLowerCase();\r\n const namespaceVars = { namespace };\r\n\r\n return [\r\n ['package.json', generatePackageJson(projectName)],\r\n ['tsconfig.json', generateTsConfig()],\r\n ['xrmforge.config.json', generateXrmForgeConfig(lowerPrefix, namespace)],\r\n ['vitest.config.ts', await loadTemplate('vitest.config.ts')],\r\n ['.gitignore', await loadTemplate('gitignore')],\r\n ['.gitattributes', generateGitAttributes()],\r\n ['AGENT.md', await loadTemplate('AGENT.md')],\r\n ['src/forms/example-form.ts', await loadTemplate('example-form.ts', namespaceVars)],\r\n ['generated/.gitkeep', ''],\r\n ['tests/forms/example-form.test.ts', await loadTemplate('example-form.test.ts', namespaceVars)],\r\n ['src/shared/logger.ts', await loadTemplate('logger.ts', namespaceVars)],\r\n ['src/shared/error-handler.ts', await loadTemplate('error-handler.ts')],\r\n ['src/shared/constants.ts', await loadTemplate('constants.ts', namespaceVars)],\r\n ['eslint.config.js', await loadTemplate('eslint.config.js')],\r\n ['.github/workflows/ci.yml', await loadTemplate('github-actions-ci.yml')],\r\n ['azure-pipelines.yml', await loadTemplate('azure-pipelines.yml')],\r\n ['scripts/validate-form.mjs', await loadTemplate('validate-form.mjs')],\r\n ];\r\n}\r\n\r\n/**\r\n * Generate package.json content for a scaffolded project.\r\n *\r\n * @param projectName - The project name for the name field\r\n * @returns Formatted JSON string\r\n */\r\nfunction generatePackageJson(projectName: string): string {\r\n const pkg = {\r\n name: projectName,\r\n version: '0.1.0',\r\n private: true,\r\n type: 'module',\r\n scripts: {\r\n generate: 'xrmforge generate',\r\n typecheck: 'tsc --noEmit',\r\n build: 'xrmforge build',\r\n watch: 'xrmforge build --watch',\r\n test: 'vitest run',\r\n 'test:watch': 'vitest',\r\n validate: 'node scripts/validate-form.mjs',\r\n },\r\n devDependencies: {\r\n '@types/xrm': '^9.0.90',\r\n '@typescript-eslint/eslint-plugin': '^8.0.0',\r\n '@typescript-eslint/parser': '^8.0.0',\r\n // 0.x caret ranges only allow the same minor: keep these pins on the\r\n // current minor of each package, otherwise scaffolded projects install\r\n // outdated versions (e.g. helpers ^0.3.0 never resolves to 0.6.x).\r\n // cli ^0.8.0: the env-var CI template (XRMFORGE_* without auth flags, since\r\n // 0.7.0) plus the ./.env auto-load and interactive prompt (0.8.0) need cli at\r\n // that minor; a 0.x caret never crosses a minor boundary, so an older pin\r\n // would hand fresh projects a cli without these features.\r\n // helpers ^0.10.0: MultiSelect/submit/app-notification helpers (parseMultiSelect,\r\n // clearAndSubmit, setUnsafeAndSubmit, addAppNotification) ship in 0.10.0 (F-MAR7-03,\r\n // F-LMA7-07/09); void Custom API executors since 0.9.0 (F-MAR7-01); isFormType since 0.8.0.\r\n // testing ^0.4.0: complex-form mocks (createFormMock formType option, attribute\r\n // getText/getPrecision, entity addOnSave/fireOnSave, roles ItemCollection,\r\n // utilityOverrides) ship in 0.4.0 (F-MAR7-02); the tabs option shipped in 0.3.0.\r\n '@xrmforge/cli': '^0.8.0',\r\n '@xrmforge/eslint-plugin': '^0.3.0',\r\n '@xrmforge/helpers': '^0.10.0',\r\n '@xrmforge/testing': '^0.4.0',\r\n eslint: '^9.0.0',\r\n typescript: '^5.7.0',\r\n vitest: '^3.0.0',\r\n },\r\n };\r\n return JSON.stringify(pkg, null, 2) + '\\n';\r\n}\r\n\r\n/**\r\n * Generate .gitattributes content for a scaffolded project.\r\n *\r\n * Pins generated declarations (and source/config) to LF. typegen writes LF,\r\n * but git with core.autocrlf=true (the Windows default) would otherwise check\r\n * the files out as CRLF, and `xrmforge generate --check` would report false\r\n * drift on every file. Forcing eol=lf keeps the drift gate green on Windows.\r\n *\r\n * @returns .gitattributes content\r\n */\r\nfunction generateGitAttributes(): string {\r\n return [\r\n '# typegen writes LF. Pin generated files to LF so `xrmforge generate --check`',\r\n '# stays stable on Windows (git core.autocrlf would otherwise serve CRLF',\r\n '# working copies and the byte comparison would report false drift).',\r\n 'generated/** text eol=lf',\r\n '',\r\n '# Keep source and config line endings consistent across platforms.',\r\n '*.ts text eol=lf',\r\n '*.mjs text eol=lf',\r\n '*.json text eol=lf',\r\n '',\r\n ].join('\\n');\r\n}\r\n\r\n/**\r\n * Generate tsconfig.json content for a scaffolded project.\r\n *\r\n * @returns Formatted JSON string with D365-appropriate compiler options\r\n */\r\nfunction generateTsConfig(): string {\r\n const config = {\r\n compilerOptions: {\r\n target: 'ES2020',\r\n module: 'ESNext',\r\n moduleResolution: 'bundler',\r\n lib: ['ES2020', 'DOM'],\r\n types: ['xrm'],\r\n strict: true,\r\n noEmit: true,\r\n skipLibCheck: false,\r\n esModuleInterop: true,\r\n },\r\n include: [\r\n 'src/**/*.ts',\r\n 'generated/**/*.ts',\r\n ],\r\n };\r\n return JSON.stringify(config, null, 2) + '\\n';\r\n}\r\n\r\n/**\r\n * Generate xrmforge.config.json content for a scaffolded project.\r\n *\r\n * @param prefix - Publisher prefix for WebResource paths (lowercase)\r\n * @param namespace - Base namespace for form script globals\r\n * @returns Formatted JSON string with a sample build entry\r\n */\r\nfunction generateXrmForgeConfig(prefix: string, namespace: string): string {\r\n const config = {\r\n build: {\r\n outDir: `./dist/${prefix}_/JS`,\r\n target: 'es2020',\r\n sourcemap: true,\r\n minify: true,\r\n entries: {\r\n example_form: {\r\n input: './src/forms/example-form.ts',\r\n namespace: `${namespace}.Example`,\r\n out: 'Example/OnLoad.js',\r\n },\r\n },\r\n },\r\n };\r\n return JSON.stringify(config, null, 2) + '\\n';\r\n}\r\n","/**\r\n * Template loader for scaffold templates.\r\n *\r\n * Reads template files from the templates/ directory relative to this module.\r\n * Supports {{placeholder}} variable substitution.\r\n */\r\n\r\nimport { readFile } from 'node:fs/promises';\r\nimport { dirname, join } from 'node:path';\r\nimport { fileURLToPath } from 'node:url';\r\n\r\nconst __dirname = dirname(fileURLToPath(import.meta.url));\r\n\r\n/** Path to the templates directory (relative to compiled output or source). */\r\nconst TEMPLATES_DIR = join(__dirname, 'templates');\r\n\r\n/**\r\n * Load a template file by name and optionally substitute variables.\r\n *\r\n * Variables in the template use the `{{key}}` syntax.\r\n *\r\n * @param name - Template filename (e.g. 'AGENT.md', 'example-form.ts')\r\n * @param vars - Optional key-value pairs for placeholder substitution\r\n * @returns Template content with variables replaced\r\n */\r\nexport async function loadTemplate(\r\n name: string,\r\n vars?: Record<string, string>,\r\n): Promise<string> {\r\n const content = await readFile(join(TEMPLATES_DIR, name), 'utf-8');\r\n\r\n if (!vars || Object.keys(vars).length === 0) {\r\n return content;\r\n }\r\n\r\n return Object.entries(vars).reduce(\r\n (result, [key, value]) => result.replaceAll(`{{${key}}}`, value),\r\n content,\r\n );\r\n}\r\n"],"mappings":"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","names":["BuildErrorCode","context","build","mkdir","join","dirname","mkdir","join"]}
|
package/dist/templates/AGENT.md
CHANGED
|
@@ -21,7 +21,7 @@ functions with domain-specific names, not in anonymous chains of API calls.
|
|
|
21
21
|
## Packages
|
|
22
22
|
|
|
23
23
|
- `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata (Node.js CLI only, NEVER import in browser code)
|
|
24
|
-
- `@xrmforge/helpers` - Browser-safe runtime: typedForm(), select(), parseLookup(), formLookup(), Xrm constants, Action executors, callCloudFlow()
|
|
24
|
+
- `@xrmforge/helpers` - Browser-safe runtime: typedForm(), select(), parseLookup(), parseMultiSelect(), formLookup(), clearAndSubmit()/setUnsafeAndSubmit(), addAppNotification(), Xrm constants, Action executors, callCloudFlow()
|
|
25
25
|
- `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange(), setupXrmMock()
|
|
26
26
|
- `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
|
|
27
27
|
- `@xrmforge/eslint-plugin` - D365-specific ESLint rules
|
|
@@ -37,7 +37,7 @@ Run `xrmforge generate` to create:
|
|
|
37
37
|
- `generated/actions/global.ts` - Custom API Action executors (typed params + results)
|
|
38
38
|
- `generated/functions/global.ts` - Custom API Function executors
|
|
39
39
|
- `generated/form-mapping.json` - Entity to form interface mapping (read after generate!)
|
|
40
|
-
- `generated/index.ts` - Barrel
|
|
40
|
+
- `generated/index.ts` - Barrel (entities + forms via `export *`); OptionSets, Fields/NavigationProperties and Actions are imported directly from their own files (name-collision-safe)
|
|
41
41
|
|
|
42
42
|
**After generate:** Read `generated/form-mapping.json` for the mapping of entity logical
|
|
43
43
|
names to form interface names. Do NOT guess interface names from entity names.
|
|
@@ -276,19 +276,26 @@ export const onLoad = wrapHandler('Namespace.Entity.onLoad', logger, async (ctx)
|
|
|
276
276
|
|
|
277
277
|
### 8. Custom API Executors from generated/actions/
|
|
278
278
|
|
|
279
|
-
Never build your own ExecuteFunctionCall wrapper. Use the generated executors
|
|
279
|
+
Never build your own ExecuteFunctionCall wrapper. Use the generated executors.
|
|
280
|
+
`execute()` reports failures itself (a non-2xx response becomes a thrown Error), so
|
|
281
|
+
NEVER check `.ok`/`.status` or call `.json()` on the result - that raw-fetch mental
|
|
282
|
+
model does not apply. The handler/command wrapper (and `withProgress`) surface the error.
|
|
280
283
|
|
|
281
284
|
```typescript
|
|
282
|
-
import { CreateEMailFromInvoice } from '../../generated/actions/global.js';
|
|
285
|
+
import { CreateEMailFromInvoice, CancelInvoice } from '../../generated/actions/global.js';
|
|
283
286
|
import { withProgress } from '@xrmforge/helpers';
|
|
284
287
|
|
|
285
|
-
//
|
|
286
|
-
//
|
|
288
|
+
// Action WITH a typed result: use the return value.
|
|
289
|
+
// withProgress(message, operation): operation is a thunk (() => Promise), message is first.
|
|
287
290
|
const result = await withProgress(
|
|
288
291
|
lang.creatingEmail,
|
|
289
292
|
() => CreateEMailFromInvoice.execute({ InvoiceId: recordId }),
|
|
290
293
|
);
|
|
291
294
|
// result.EmailId is typed as string
|
|
295
|
+
|
|
296
|
+
// Action WITHOUT a typed result (void): just await it, there is no return value.
|
|
297
|
+
await CancelInvoice.execute({ InvoiceId: recordId });
|
|
298
|
+
// WRONG: if (!(await CancelInvoice.execute(...)).ok) { ... } -> void result has no .ok
|
|
292
299
|
```
|
|
293
300
|
|
|
294
301
|
### 9. Named constants for ALL non-obvious values
|
|
@@ -417,11 +424,14 @@ Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); //
|
|
|
417
424
|
- Never wrap a `XxxFields` lookup value again as `` `_${XxxFields.X}_value` `` (it is already `_value`-form; double-wrap -> `__..._value_value` -> OData 400). Use the Fields value directly in `$select`/`$filter`; use `XxxNavigationProperties` for `parseLookup`/`$expand`/`@odata.bind`
|
|
418
425
|
- Never raw strings in `$unsafe()` (use Entity-level Fields Enum: `form.$unsafe(AccountFields.X)`)
|
|
419
426
|
- Never manual OData annotation access (`_value`, `@OData.Community.Display.V1.FormattedValue`, `@Microsoft.Dynamics.CRM.lookuplogicalname`). Use `parseLookup()` which extracts all three.
|
|
427
|
+
- Never hardcode an `@odata.bind` EntitySet plural - the set name is NOT the entity logical name. Resolve it via `(await Xrm.Utility.getEntityMetadata(logicalName)).EntitySetName`, then build `` `${navProperty}@odata.bind`: `/${entitySetName}(${id})` `` (there is no helper: the plural needs a metadata lookup, which is project-specific and worth caching)
|
|
420
428
|
|
|
421
429
|
**Code quality:**
|
|
422
430
|
- Never `Xrm.Page` (deprecated since D365 v9.0)
|
|
423
431
|
- Never `eval()`, never synchronous XMLHttpRequest
|
|
424
432
|
- Never hand-write `fetch`/`XMLHttpRequest` for Power Automate cloud-flow HTTP-trigger calls (use `callCloudFlow(flowUrl, body)` from `@xrmforge/helpers`)
|
|
433
|
+
- Never check `.ok`/`.status` or call `.json()` on a Custom API executor result (`execute()` throws on failure; a void action returns nothing, so `if (!resp.ok)` crashes at runtime with `response.json is not a function`)
|
|
434
|
+
- Never hand-build a MultiSelect parser, an off-form set-and-submit, a clear-and-submit, or an app-notification-level cast (use `parseMultiSelect`, `setUnsafeAndSubmit`, `clearAndSubmit`, `addAppNotification`/`AppNotificationLevel` from `@xrmforge/helpers`)
|
|
425
435
|
- Never `window.X = ...` (use module exports)
|
|
426
436
|
- Never `console.log/warn/error` in form scripts (use shared logger)
|
|
427
437
|
- Never export handlers without `wrapHandler()`
|
|
@@ -448,7 +458,7 @@ Copy these MANDATORY rules into every sub-agent prompt:
|
|
|
448
458
|
9. SaveMode/FormType/DisplayState/RequiredLevel/SubmitMode/FormNotificationLevel constants
|
|
449
459
|
10. wrapHandler() around EVERY exported handler
|
|
450
460
|
11. createLogger() instead of console.* (except logger.ts)
|
|
451
|
-
12. Custom API Executors from generated/actions/ (never build your own)
|
|
461
|
+
12. Custom API Executors from generated/actions/ (never build your own; execute() throws on failure - never check .ok; a void action returns nothing, just await)
|
|
452
462
|
13. NOTIFICATION_IDS from constants.ts for all notification unique IDs
|
|
453
463
|
14. Named constants for non-obvious values (never magic numbers like 86400000)
|
|
454
464
|
15. pickLang() for all user-visible strings (never hardcoded German/English)
|
|
@@ -555,7 +565,8 @@ if (customer) {
|
|
|
555
565
|
// BEFORE: ExecuteFunctionCall("CancelInvoice", { InvoiceId: id })
|
|
556
566
|
// AFTER:
|
|
557
567
|
import { CancelInvoice } from '../../generated/actions/global.js';
|
|
558
|
-
|
|
568
|
+
// CancelInvoice returns no result: execute() throws on failure, so just await it.
|
|
569
|
+
await withProgress(
|
|
559
570
|
lang.cancellingInvoice,
|
|
560
571
|
() => CancelInvoice.execute({ InvoiceId: id }),
|
|
561
572
|
);
|
|
@@ -626,6 +637,17 @@ Every onChange handler MUST have a `fireOnChange` test.
|
|
|
626
637
|
**attr.controls:** Since @xrmforge/testing 0.2.3, `createFormMock()` automatically links
|
|
627
638
|
each attribute to its control. `mock.getControl(Fields.Name)` works out of the box.
|
|
628
639
|
|
|
640
|
+
**Complex forms (since @xrmforge/testing 0.4.0):**
|
|
641
|
+
- `createFormMock(values, { formType: 1 })` seeds the form type, so Create-only paths
|
|
642
|
+
(`isFormType(ctx, FormType.Create)`) are testable.
|
|
643
|
+
- On an attribute: `mock.getAttribute(Fields.X).setText('B2B')` / `.setPrecision(2)` seed
|
|
644
|
+
`getText()` (OptionSet label) and `getPrecision()` (number precision).
|
|
645
|
+
- `formContext.data.entity.addOnSave(...)` is supported; trigger it in a test with
|
|
646
|
+
`mock.fireOnSave(saveMode)` (returns true if a handler called `preventDefault()`).
|
|
647
|
+
- `setupXrmMock({ globalContextOverrides: { roles: [{ id, name, entityType: 'role' }] },
|
|
648
|
+
utilityOverrides: { getEntityMetadata } })` seeds the `userSettings.roles` ItemCollection
|
|
649
|
+
(`get()/forEach/getLength`) and overrides `Xrm.Utility.getEntityMetadata`.
|
|
650
|
+
|
|
629
651
|
## Pattern Recognition: Legacy to XrmForge
|
|
630
652
|
|
|
631
653
|
### Xrm API Patterns
|
|
@@ -739,6 +761,9 @@ IDE autocomplete. Only keep shared helpers that contain actual domain logic
|
|
|
739
761
|
8. **Xrm.App.addGlobalNotification level** is typed as `XrmEnum.AppNotificationLevel`, which (like
|
|
740
762
|
all `XrmEnum`) does not exist at runtime. Pass the numeric value with a cast/comment, not
|
|
741
763
|
`XrmEnum.AppNotificationLevel.X`.
|
|
764
|
+
9. **Xrm.LookupValue.name** is typed `string | undefined`. Coalesce it before passing it to a string
|
|
765
|
+
setter or a template literal: `lookup.name ?? ''`. The `parseLookup`/`formLookup` helpers already
|
|
766
|
+
return `name` as `''`, so this only bites on a raw `getValue()[0].name` access.
|
|
742
767
|
|
|
743
768
|
## Build
|
|
744
769
|
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Central constants for notifications and messages.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/** Unique IDs for form-level notifications. */
|
|
6
|
-
export const NOTIFICATION_IDS = {
|
|
7
|
-
genericError: '{{namespace}}.notification.generic-error'.toLowerCase(),
|
|
8
|
-
} as const;
|
|
9
|
-
|
|
10
|
-
/** Localized message strings (extend as needed). */
|
|
11
|
-
export const MESSAGES = {
|
|
12
|
-
de: {
|
|
13
|
-
unsavedRecord: 'Der Datensatz muss zuerst gespeichert werden.',
|
|
14
|
-
},
|
|
15
|
-
en: {
|
|
16
|
-
unsavedRecord: 'The record must be saved first.',
|
|
17
|
-
},
|
|
18
|
-
} as const;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Pick the correct language table based on the user's D365 language setting.
|
|
22
|
-
*
|
|
23
|
-
* @param languageId - LCID from Xrm.Utility.getGlobalContext().userSettings.languageId
|
|
24
|
-
* @param table - Object with 'de' and 'en' keys containing the same message keys
|
|
25
|
-
* @returns The matching language table (defaults to English)
|
|
26
|
-
*/
|
|
27
|
-
export function pickLang<K extends string>(
|
|
28
|
-
languageId: number,
|
|
29
|
-
table: { de: Record<K, string>; en: Record<K, string> },
|
|
30
|
-
): Record<K, string> {
|
|
31
|
-
return languageId === 1031 ? table.de : table.en;
|
|
32
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Central constants for notifications and messages.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Unique IDs for form-level notifications. */
|
|
6
|
+
export const NOTIFICATION_IDS = {
|
|
7
|
+
genericError: '{{namespace}}.notification.generic-error'.toLowerCase(),
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
/** Localized message strings (extend as needed). */
|
|
11
|
+
export const MESSAGES = {
|
|
12
|
+
de: {
|
|
13
|
+
unsavedRecord: 'Der Datensatz muss zuerst gespeichert werden.',
|
|
14
|
+
},
|
|
15
|
+
en: {
|
|
16
|
+
unsavedRecord: 'The record must be saved first.',
|
|
17
|
+
},
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pick the correct language table based on the user's D365 language setting.
|
|
22
|
+
*
|
|
23
|
+
* @param languageId - LCID from Xrm.Utility.getGlobalContext().userSettings.languageId
|
|
24
|
+
* @param table - Object with 'de' and 'en' keys containing the same message keys
|
|
25
|
+
* @returns The matching language table (defaults to English)
|
|
26
|
+
*/
|
|
27
|
+
export function pickLang<K extends string>(
|
|
28
|
+
languageId: number,
|
|
29
|
+
table: { de: Record<K, string>; en: Record<K, string> },
|
|
30
|
+
): Record<K, string> {
|
|
31
|
+
return languageId === 1031 ? table.de : table.en;
|
|
32
|
+
}
|
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified error handling for D365 form event handlers.
|
|
3
|
-
* Wraps sync and async handlers with try/catch and form notifications.
|
|
4
|
-
*/
|
|
5
|
-
import type { Logger } from './logger.js';
|
|
6
|
-
import { NOTIFICATION_IDS } from './constants.js';
|
|
7
|
-
import { FormNotificationLevel } from '@xrmforge/helpers';
|
|
8
|
-
|
|
9
|
-
type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Wrap a form event handler with error handling.
|
|
13
|
-
*
|
|
14
|
-
* Catches both sync and async errors, logs them, and shows a form notification.
|
|
15
|
-
* The original handler is never rethrown, so form execution continues.
|
|
16
|
-
*
|
|
17
|
-
* @param name - Handler name for logging (e.g. 'MyApp.Account.onLoad')
|
|
18
|
-
* @param logger - Logger instance for error reporting
|
|
19
|
-
* @param handler - The actual event handler function
|
|
20
|
-
*/
|
|
21
|
-
export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler {
|
|
22
|
-
const wrapped: EventHandler = (ctx, ...args) => {
|
|
23
|
-
try {
|
|
24
|
-
const result = handler(ctx, ...args);
|
|
25
|
-
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
26
|
-
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
27
|
-
logAndNotify(ctx, name, logger, err);
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
return result;
|
|
31
|
-
} catch (err: unknown) {
|
|
32
|
-
logAndNotify(ctx, name, logger, err);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
return wrapped;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Wrap a ribbon command handler with error handling.
|
|
40
|
-
*
|
|
41
|
-
* Unlike wrapHandler, this accepts a FormContext directly (not an EventContext),
|
|
42
|
-
* which is the calling convention for ribbon/command bar handlers.
|
|
43
|
-
*
|
|
44
|
-
* @param name - Handler name for logging
|
|
45
|
-
* @param logger - Logger instance for error reporting
|
|
46
|
-
* @param handler - The actual command handler function
|
|
47
|
-
*/
|
|
48
|
-
export function wrapCommand(
|
|
49
|
-
name: string,
|
|
50
|
-
logger: Logger,
|
|
51
|
-
handler: (formContext: Xrm.FormContext, ...args: never[]) => unknown,
|
|
52
|
-
): (formContext: Xrm.FormContext, ...args: never[]) => unknown {
|
|
53
|
-
return (formContext, ...args) => {
|
|
54
|
-
try {
|
|
55
|
-
const result = handler(formContext, ...args);
|
|
56
|
-
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
57
|
-
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
58
|
-
logAndNotifyForm(formContext, name, logger, err);
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
return result;
|
|
62
|
-
} catch (err: unknown) {
|
|
63
|
-
logAndNotifyForm(formContext, name, logger, err);
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function logAndNotify(
|
|
69
|
-
ctx: Xrm.Events.EventContext,
|
|
70
|
-
name: string,
|
|
71
|
-
logger: Logger,
|
|
72
|
-
err: unknown,
|
|
73
|
-
): void {
|
|
74
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
-
logger.error(`${name} failed`, { err });
|
|
76
|
-
try {
|
|
77
|
-
ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
78
|
-
} catch {
|
|
79
|
-
/* ignore */
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function logAndNotifyForm(
|
|
84
|
-
formContext: Xrm.FormContext,
|
|
85
|
-
name: string,
|
|
86
|
-
logger: Logger,
|
|
87
|
-
err: unknown,
|
|
88
|
-
): void {
|
|
89
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
-
logger.error(`${name} failed`, { err });
|
|
91
|
-
try {
|
|
92
|
-
formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
93
|
-
} catch {
|
|
94
|
-
/* ignore */
|
|
95
|
-
}
|
|
96
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Unified error handling for D365 form event handlers.
|
|
3
|
+
* Wraps sync and async handlers with try/catch and form notifications.
|
|
4
|
+
*/
|
|
5
|
+
import type { Logger } from './logger.js';
|
|
6
|
+
import { NOTIFICATION_IDS } from './constants.js';
|
|
7
|
+
import { FormNotificationLevel } from '@xrmforge/helpers';
|
|
8
|
+
|
|
9
|
+
type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wrap a form event handler with error handling.
|
|
13
|
+
*
|
|
14
|
+
* Catches both sync and async errors, logs them, and shows a form notification.
|
|
15
|
+
* The original handler is never rethrown, so form execution continues.
|
|
16
|
+
*
|
|
17
|
+
* @param name - Handler name for logging (e.g. 'MyApp.Account.onLoad')
|
|
18
|
+
* @param logger - Logger instance for error reporting
|
|
19
|
+
* @param handler - The actual event handler function
|
|
20
|
+
*/
|
|
21
|
+
export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler {
|
|
22
|
+
const wrapped: EventHandler = (ctx, ...args) => {
|
|
23
|
+
try {
|
|
24
|
+
const result = handler(ctx, ...args);
|
|
25
|
+
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
26
|
+
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
27
|
+
logAndNotify(ctx, name, logger, err);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
} catch (err: unknown) {
|
|
32
|
+
logAndNotify(ctx, name, logger, err);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return wrapped;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wrap a ribbon command handler with error handling.
|
|
40
|
+
*
|
|
41
|
+
* Unlike wrapHandler, this accepts a FormContext directly (not an EventContext),
|
|
42
|
+
* which is the calling convention for ribbon/command bar handlers.
|
|
43
|
+
*
|
|
44
|
+
* @param name - Handler name for logging
|
|
45
|
+
* @param logger - Logger instance for error reporting
|
|
46
|
+
* @param handler - The actual command handler function
|
|
47
|
+
*/
|
|
48
|
+
export function wrapCommand(
|
|
49
|
+
name: string,
|
|
50
|
+
logger: Logger,
|
|
51
|
+
handler: (formContext: Xrm.FormContext, ...args: never[]) => unknown,
|
|
52
|
+
): (formContext: Xrm.FormContext, ...args: never[]) => unknown {
|
|
53
|
+
return (formContext, ...args) => {
|
|
54
|
+
try {
|
|
55
|
+
const result = handler(formContext, ...args);
|
|
56
|
+
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
57
|
+
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
58
|
+
logAndNotifyForm(formContext, name, logger, err);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
} catch (err: unknown) {
|
|
63
|
+
logAndNotifyForm(formContext, name, logger, err);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function logAndNotify(
|
|
69
|
+
ctx: Xrm.Events.EventContext,
|
|
70
|
+
name: string,
|
|
71
|
+
logger: Logger,
|
|
72
|
+
err: unknown,
|
|
73
|
+
): void {
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
+
logger.error(`${name} failed`, { err });
|
|
76
|
+
try {
|
|
77
|
+
ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function logAndNotifyForm(
|
|
84
|
+
formContext: Xrm.FormContext,
|
|
85
|
+
name: string,
|
|
86
|
+
logger: Logger,
|
|
87
|
+
err: unknown,
|
|
88
|
+
): void {
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
logger.error(`${name} failed`, { err });
|
|
91
|
+
try {
|
|
92
|
+
formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
93
|
+
} catch {
|
|
94
|
+
/* ignore */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import tseslint from '@typescript-eslint/eslint-plugin';
|
|
2
|
-
import tsparser from '@typescript-eslint/parser';
|
|
3
|
-
import xrmforge from '@xrmforge/eslint-plugin';
|
|
4
|
-
|
|
5
|
-
export default [
|
|
6
|
-
{
|
|
7
|
-
files: ['src/**/*.ts'],
|
|
8
|
-
languageOptions: {
|
|
9
|
-
parser: tsparser,
|
|
10
|
-
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
|
11
|
-
},
|
|
12
|
-
plugins: { '@typescript-eslint': tseslint },
|
|
13
|
-
rules: {
|
|
14
|
-
'@typescript-eslint/no-explicit-any': 'error',
|
|
15
|
-
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
16
|
-
},
|
|
17
|
-
},
|
|
18
|
-
xrmforge.configs.recommended,
|
|
19
|
-
{ rules: { 'no-console': ['error'] } },
|
|
20
|
-
{ files: ['src/shared/logger.ts'], rules: { 'no-console': 'off' } },
|
|
21
|
-
];
|
|
1
|
+
import tseslint from '@typescript-eslint/eslint-plugin';
|
|
2
|
+
import tsparser from '@typescript-eslint/parser';
|
|
3
|
+
import xrmforge from '@xrmforge/eslint-plugin';
|
|
4
|
+
|
|
5
|
+
export default [
|
|
6
|
+
{
|
|
7
|
+
files: ['src/**/*.ts'],
|
|
8
|
+
languageOptions: {
|
|
9
|
+
parser: tsparser,
|
|
10
|
+
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
|
11
|
+
},
|
|
12
|
+
plugins: { '@typescript-eslint': tseslint },
|
|
13
|
+
rules: {
|
|
14
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
15
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
xrmforge.configs.recommended,
|
|
19
|
+
{ rules: { 'no-console': ['error'] } },
|
|
20
|
+
{ files: ['src/shared/logger.ts'], rules: { 'no-console': 'off' } },
|
|
21
|
+
];
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Example test for the form script.
|
|
5
|
-
*
|
|
6
|
-
* Uses @xrmforge/testing for type-safe mocking once you have
|
|
7
|
-
* generated types. Replace with real form tests after 'xrmforge generate'.
|
|
8
|
-
*/
|
|
9
|
-
describe('{{namespace}}.Example', () => {
|
|
10
|
-
it('should export onLoad handler', async () => {
|
|
11
|
-
const mod = await import('../../src/forms/example-form.js');
|
|
12
|
-
expect(typeof mod.onLoad).toBe('function');
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('should export onSave handler', async () => {
|
|
16
|
-
const mod = await import('../../src/forms/example-form.js');
|
|
17
|
-
expect(typeof mod.onSave).toBe('function');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
// TODO: After running 'xrmforge generate', add real form tests.
|
|
21
|
-
// createFormMock<TForm>() takes the generated FORM interface and returns a
|
|
22
|
-
// FormMock: use mock.asEventContext() for the handler and mock.ui /
|
|
23
|
-
// mock.getControl() for assertions (plain mocks, not vi.fn() spies).
|
|
24
|
-
//
|
|
25
|
-
// import { createFormMock } from '@xrmforge/testing';
|
|
26
|
-
// import type { ExampleForm } from '../../generated/forms/example.js';
|
|
27
|
-
//
|
|
28
|
-
// it('should show notification on load', () => {
|
|
29
|
-
// const mock = createFormMock<ExampleForm>({ name: 'Contoso Ltd' });
|
|
30
|
-
// onLoad(mock.asEventContext());
|
|
31
|
-
// expect(mock.ui.getFormNotification('example-load')?.message).toBe('Form loaded');
|
|
32
|
-
// });
|
|
33
|
-
});
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example test for the form script.
|
|
5
|
+
*
|
|
6
|
+
* Uses @xrmforge/testing for type-safe mocking once you have
|
|
7
|
+
* generated types. Replace with real form tests after 'xrmforge generate'.
|
|
8
|
+
*/
|
|
9
|
+
describe('{{namespace}}.Example', () => {
|
|
10
|
+
it('should export onLoad handler', async () => {
|
|
11
|
+
const mod = await import('../../src/forms/example-form.js');
|
|
12
|
+
expect(typeof mod.onLoad).toBe('function');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should export onSave handler', async () => {
|
|
16
|
+
const mod = await import('../../src/forms/example-form.js');
|
|
17
|
+
expect(typeof mod.onSave).toBe('function');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// TODO: After running 'xrmforge generate', add real form tests.
|
|
21
|
+
// createFormMock<TForm>() takes the generated FORM interface and returns a
|
|
22
|
+
// FormMock: use mock.asEventContext() for the handler and mock.ui /
|
|
23
|
+
// mock.getControl() for assertions (plain mocks, not vi.fn() spies).
|
|
24
|
+
//
|
|
25
|
+
// import { createFormMock } from '@xrmforge/testing';
|
|
26
|
+
// import type { ExampleForm } from '../../generated/forms/example.js';
|
|
27
|
+
//
|
|
28
|
+
// it('should show notification on load', () => {
|
|
29
|
+
// const mock = createFormMock<ExampleForm>({ name: 'Contoso Ltd' });
|
|
30
|
+
// onLoad(mock.asEventContext());
|
|
31
|
+
// expect(mock.ui.getFormNotification('example-load')?.message).toBe('Form loaded');
|
|
32
|
+
// });
|
|
33
|
+
});
|
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Example Form Script for Dynamics 365 using XrmForge best practices.
|
|
3
|
-
*
|
|
4
|
-
* Register in D365 as: {{namespace}}.Example.onLoad
|
|
5
|
-
*
|
|
6
|
-
* This template demonstrates the correct patterns:
|
|
7
|
-
* - typedForm() for direct typed field access
|
|
8
|
-
* - Fields Enum for addOnChange via $context, controls proxy for control access
|
|
9
|
-
* - Entity-level Fields for Web API select() queries
|
|
10
|
-
* - wrapHandler for unified error handling
|
|
11
|
-
* - Logger instead of console.log
|
|
12
|
-
* - FormNotificationLevel constant instead of raw string
|
|
13
|
-
* - pickLang() for localized UI strings
|
|
14
|
-
*
|
|
15
|
-
* Replace this file with your actual form logic after running 'xrmforge generate'.
|
|
16
|
-
*/
|
|
17
|
-
import { createLogger } from '../shared/logger.js';
|
|
18
|
-
import { wrapHandler } from '../shared/error-handler.js';
|
|
19
|
-
import { MESSAGES, pickLang } from '../shared/constants.js';
|
|
20
|
-
// For Web API queries and lookups also import: select, formLookupId
|
|
21
|
-
import { typedForm, FormNotificationLevel } from '@xrmforge/helpers';
|
|
22
|
-
// TODO: After 'xrmforge generate', replace with your actual imports:
|
|
23
|
-
// import type { ExampleFormTypeInfo } from '../../generated/forms/example.js';
|
|
24
|
-
// import { ExampleFormFieldsEnum as Fields } from '../../generated/forms/example.js';
|
|
25
|
-
// import { ExampleFields } from '../../generated/fields/example.js';
|
|
26
|
-
// import { EntityNames } from '../../generated/entity-names.js';
|
|
27
|
-
|
|
28
|
-
const logger = createLogger('{{namespace}}.Example');
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Called when the form loads.
|
|
32
|
-
*/
|
|
33
|
-
export const onLoad = wrapHandler('{{namespace}}.Example.onLoad', logger, async (ctx) => {
|
|
34
|
-
// TODO: Replace 'Xrm.FormContext' with your generated <Form>TypeInfo type:
|
|
35
|
-
// const form = typedForm<ExampleFormTypeInfo>(ctx.getFormContext());
|
|
36
|
-
const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
|
|
37
|
-
|
|
38
|
-
// Direct field access via typedForm proxy (fully typed):
|
|
39
|
-
// const name = form.name.getValue(); // string | null
|
|
40
|
-
// form.revenue.setValue(150000); // NumberAttribute
|
|
41
|
-
|
|
42
|
-
// addOnChange uses Fields Enum via $context:
|
|
43
|
-
// form.$context.getAttribute(Fields.Name).addOnChange(() => {
|
|
44
|
-
// logger.debug('Name changed', { value: form.name.getValue() });
|
|
45
|
-
// });
|
|
46
|
-
|
|
47
|
-
// Web API queries use entity-level Fields:
|
|
48
|
-
// const result = await Xrm.WebApi.retrieveRecord(
|
|
49
|
-
// EntityNames.Account, id,
|
|
50
|
-
// select(ExampleFields.Name, ExampleFields.WebsiteUrl)
|
|
51
|
-
// );
|
|
52
|
-
|
|
53
|
-
// Lookup access:
|
|
54
|
-
// const parentId = formLookupId(form.parentaccountid);
|
|
55
|
-
|
|
56
|
-
// Localized UI strings:
|
|
57
|
-
const lang = pickLang(
|
|
58
|
-
Xrm.Utility.getGlobalContext().userSettings.languageId,
|
|
59
|
-
MESSAGES,
|
|
60
|
-
);
|
|
61
|
-
logger.info('Form loaded', { language: lang });
|
|
62
|
-
|
|
63
|
-
// Form notifications use constants:
|
|
64
|
-
form.$context.ui.setFormNotification(
|
|
65
|
-
'Form loaded',
|
|
66
|
-
FormNotificationLevel.Info,
|
|
67
|
-
'example-load',
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Called when the form is saved.
|
|
73
|
-
*/
|
|
74
|
-
export const onSave = wrapHandler('{{namespace}}.Example.onSave', logger, (ctx) => {
|
|
75
|
-
const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
|
|
76
|
-
form.$context.ui.clearFormNotification('example-load');
|
|
77
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Example Form Script for Dynamics 365 using XrmForge best practices.
|
|
3
|
+
*
|
|
4
|
+
* Register in D365 as: {{namespace}}.Example.onLoad
|
|
5
|
+
*
|
|
6
|
+
* This template demonstrates the correct patterns:
|
|
7
|
+
* - typedForm() for direct typed field access
|
|
8
|
+
* - Fields Enum for addOnChange via $context, controls proxy for control access
|
|
9
|
+
* - Entity-level Fields for Web API select() queries
|
|
10
|
+
* - wrapHandler for unified error handling
|
|
11
|
+
* - Logger instead of console.log
|
|
12
|
+
* - FormNotificationLevel constant instead of raw string
|
|
13
|
+
* - pickLang() for localized UI strings
|
|
14
|
+
*
|
|
15
|
+
* Replace this file with your actual form logic after running 'xrmforge generate'.
|
|
16
|
+
*/
|
|
17
|
+
import { createLogger } from '../shared/logger.js';
|
|
18
|
+
import { wrapHandler } from '../shared/error-handler.js';
|
|
19
|
+
import { MESSAGES, pickLang } from '../shared/constants.js';
|
|
20
|
+
// For Web API queries and lookups also import: select, formLookupId
|
|
21
|
+
import { typedForm, FormNotificationLevel } from '@xrmforge/helpers';
|
|
22
|
+
// TODO: After 'xrmforge generate', replace with your actual imports:
|
|
23
|
+
// import type { ExampleFormTypeInfo } from '../../generated/forms/example.js';
|
|
24
|
+
// import { ExampleFormFieldsEnum as Fields } from '../../generated/forms/example.js';
|
|
25
|
+
// import { ExampleFields } from '../../generated/fields/example.js';
|
|
26
|
+
// import { EntityNames } from '../../generated/entity-names.js';
|
|
27
|
+
|
|
28
|
+
const logger = createLogger('{{namespace}}.Example');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Called when the form loads.
|
|
32
|
+
*/
|
|
33
|
+
export const onLoad = wrapHandler('{{namespace}}.Example.onLoad', logger, async (ctx) => {
|
|
34
|
+
// TODO: Replace 'Xrm.FormContext' with your generated <Form>TypeInfo type:
|
|
35
|
+
// const form = typedForm<ExampleFormTypeInfo>(ctx.getFormContext());
|
|
36
|
+
const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
|
|
37
|
+
|
|
38
|
+
// Direct field access via typedForm proxy (fully typed):
|
|
39
|
+
// const name = form.name.getValue(); // string | null
|
|
40
|
+
// form.revenue.setValue(150000); // NumberAttribute
|
|
41
|
+
|
|
42
|
+
// addOnChange uses Fields Enum via $context:
|
|
43
|
+
// form.$context.getAttribute(Fields.Name).addOnChange(() => {
|
|
44
|
+
// logger.debug('Name changed', { value: form.name.getValue() });
|
|
45
|
+
// });
|
|
46
|
+
|
|
47
|
+
// Web API queries use entity-level Fields:
|
|
48
|
+
// const result = await Xrm.WebApi.retrieveRecord(
|
|
49
|
+
// EntityNames.Account, id,
|
|
50
|
+
// select(ExampleFields.Name, ExampleFields.WebsiteUrl)
|
|
51
|
+
// );
|
|
52
|
+
|
|
53
|
+
// Lookup access:
|
|
54
|
+
// const parentId = formLookupId(form.parentaccountid);
|
|
55
|
+
|
|
56
|
+
// Localized UI strings:
|
|
57
|
+
const lang = pickLang(
|
|
58
|
+
Xrm.Utility.getGlobalContext().userSettings.languageId,
|
|
59
|
+
MESSAGES,
|
|
60
|
+
);
|
|
61
|
+
logger.info('Form loaded', { language: lang });
|
|
62
|
+
|
|
63
|
+
// Form notifications use constants:
|
|
64
|
+
form.$context.ui.setFormNotification(
|
|
65
|
+
'Form loaded',
|
|
66
|
+
FormNotificationLevel.Info,
|
|
67
|
+
'example-load',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Called when the form is saved.
|
|
73
|
+
*/
|
|
74
|
+
export const onSave = wrapHandler('{{namespace}}.Example.onSave', logger, (ctx) => {
|
|
75
|
+
const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
|
|
76
|
+
form.$context.ui.clearFormNotification('example-load');
|
|
77
|
+
});
|
package/dist/templates/logger.ts
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Structured logger for D365 form scripts.
|
|
3
|
-
* This is the ONLY file allowed to use console.* directly.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/** Logger interface for structured logging with namespace prefix. */
|
|
7
|
-
export interface Logger {
|
|
8
|
-
debug(message: string, data?: unknown): void;
|
|
9
|
-
info(message: string, data?: unknown): void;
|
|
10
|
-
warn(message: string, data?: unknown): void;
|
|
11
|
-
error(message: string, data?: unknown): void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const DEBUG_STORAGE_KEY = '{{namespace}}.debug'.toLowerCase();
|
|
15
|
-
|
|
16
|
-
/** Check if the current host is a dev/test environment. */
|
|
17
|
-
function isDebugHost(): boolean {
|
|
18
|
-
try {
|
|
19
|
-
const url = Xrm.Utility.getGlobalContext().getClientUrl() ?? '';
|
|
20
|
-
if (url.includes('-dev') || url.includes('-test')) return true;
|
|
21
|
-
} catch {
|
|
22
|
-
/* ignore */
|
|
23
|
-
}
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Check if debug mode is enabled via localStorage. */
|
|
28
|
-
function isDebugStorage(): boolean {
|
|
29
|
-
try {
|
|
30
|
-
return window?.localStorage?.getItem(DEBUG_STORAGE_KEY) === '1';
|
|
31
|
-
} catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Create a namespaced logger instance.
|
|
38
|
-
*
|
|
39
|
-
* Debug messages are only shown in dev/test environments or when
|
|
40
|
-
* localStorage key is set to '1'.
|
|
41
|
-
*
|
|
42
|
-
* @param namespace - Prefix for all log messages (e.g. 'MyApp.Account')
|
|
43
|
-
*/
|
|
44
|
-
export function createLogger(namespace: string): Logger {
|
|
45
|
-
const prefix = `[${namespace}]`;
|
|
46
|
-
const debugEnabled = isDebugHost() || isDebugStorage();
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
debug(message, data) {
|
|
50
|
-
if (!debugEnabled) return;
|
|
51
|
-
if (data !== undefined) console.debug(prefix, message, data);
|
|
52
|
-
else console.debug(prefix, message);
|
|
53
|
-
},
|
|
54
|
-
info(message, data) {
|
|
55
|
-
if (data !== undefined) console.info(prefix, message, data);
|
|
56
|
-
else console.info(prefix, message);
|
|
57
|
-
},
|
|
58
|
-
warn(message, data) {
|
|
59
|
-
if (data !== undefined) console.warn(prefix, message, data);
|
|
60
|
-
else console.warn(prefix, message);
|
|
61
|
-
},
|
|
62
|
-
error(message, data) {
|
|
63
|
-
if (data !== undefined) console.error(prefix, message, data);
|
|
64
|
-
else console.error(prefix, message);
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Structured logger for D365 form scripts.
|
|
3
|
+
* This is the ONLY file allowed to use console.* directly.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Logger interface for structured logging with namespace prefix. */
|
|
7
|
+
export interface Logger {
|
|
8
|
+
debug(message: string, data?: unknown): void;
|
|
9
|
+
info(message: string, data?: unknown): void;
|
|
10
|
+
warn(message: string, data?: unknown): void;
|
|
11
|
+
error(message: string, data?: unknown): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEBUG_STORAGE_KEY = '{{namespace}}.debug'.toLowerCase();
|
|
15
|
+
|
|
16
|
+
/** Check if the current host is a dev/test environment. */
|
|
17
|
+
function isDebugHost(): boolean {
|
|
18
|
+
try {
|
|
19
|
+
const url = Xrm.Utility.getGlobalContext().getClientUrl() ?? '';
|
|
20
|
+
if (url.includes('-dev') || url.includes('-test')) return true;
|
|
21
|
+
} catch {
|
|
22
|
+
/* ignore */
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Check if debug mode is enabled via localStorage. */
|
|
28
|
+
function isDebugStorage(): boolean {
|
|
29
|
+
try {
|
|
30
|
+
return window?.localStorage?.getItem(DEBUG_STORAGE_KEY) === '1';
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a namespaced logger instance.
|
|
38
|
+
*
|
|
39
|
+
* Debug messages are only shown in dev/test environments or when
|
|
40
|
+
* localStorage key is set to '1'.
|
|
41
|
+
*
|
|
42
|
+
* @param namespace - Prefix for all log messages (e.g. 'MyApp.Account')
|
|
43
|
+
*/
|
|
44
|
+
export function createLogger(namespace: string): Logger {
|
|
45
|
+
const prefix = `[${namespace}]`;
|
|
46
|
+
const debugEnabled = isDebugHost() || isDebugStorage();
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
debug(message, data) {
|
|
50
|
+
if (!debugEnabled) return;
|
|
51
|
+
if (data !== undefined) console.debug(prefix, message, data);
|
|
52
|
+
else console.debug(prefix, message);
|
|
53
|
+
},
|
|
54
|
+
info(message, data) {
|
|
55
|
+
if (data !== undefined) console.info(prefix, message, data);
|
|
56
|
+
else console.info(prefix, message);
|
|
57
|
+
},
|
|
58
|
+
warn(message, data) {
|
|
59
|
+
if (data !== undefined) console.warn(prefix, message, data);
|
|
60
|
+
else console.warn(prefix, message);
|
|
61
|
+
},
|
|
62
|
+
error(message, data) {
|
|
63
|
+
if (data !== undefined) console.error(prefix, message, data);
|
|
64
|
+
else console.error(prefix, message);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|