@xrmforge/devkit 0.5.1 → 0.5.3

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 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/dist/index.d.ts CHANGED
@@ -40,12 +40,25 @@ interface ResolvedBuildConfig {
40
40
  external: string[];
41
41
  }
42
42
  /**
43
- * Validate a raw build config object.
44
- * Throws BuildError with CONFIG_INVALID if any required field is missing or invalid.
43
+ * Validate a raw build config object parsed from xrmforge.config.json.
44
+ *
45
+ * Checks that all required fields (entries, input, namespace) are present
46
+ * and have valid types. Throws {@link BuildError} with CONFIG_INVALID if
47
+ * any validation check fails.
48
+ *
49
+ * @param raw - Untyped config object to validate
50
+ * @returns The validated build configuration
51
+ * @throws {BuildError} If any required field is missing or has an invalid type
45
52
  */
46
53
  declare function validateBuildConfig(raw: unknown): BuildConfig;
47
54
  /**
48
55
  * Apply default values to a validated build config.
56
+ *
57
+ * Fills in defaults for optional fields: bundler ('esbuild'), outDir ('./dist'),
58
+ * target ('es2020'), sourcemap (true), minify (false), external ([]).
59
+ *
60
+ * @param config - Validated build configuration
61
+ * @returns Fully resolved configuration with all defaults applied
49
62
  */
50
63
  declare function resolveBuildConfig(config: BuildConfig): ResolvedBuildConfig;
51
64
 
@@ -163,12 +176,30 @@ declare enum BuildErrorCode {
163
176
  WATCH_ERROR = "BUILD_6004"
164
177
  }
165
178
  /**
166
- * Error class for build operations.
167
- * Carries a machine-readable code and optional context for debugging.
179
+ * Structured error class for build operations.
180
+ *
181
+ * Carries a machine-readable {@link BuildErrorCode} and optional context
182
+ * for debugging. The error message is prefixed with the code (e.g. `[BUILD_6001]`).
183
+ *
184
+ * @example
185
+ * ```typescript
186
+ * throw new BuildError(
187
+ * BuildErrorCode.ENTRY_NOT_FOUND,
188
+ * 'Could not find entry point: ./src/missing.ts',
189
+ * { entry: 'my_script' },
190
+ * );
191
+ * ```
168
192
  */
169
193
  declare class BuildError extends Error {
194
+ /** Machine-readable error code for programmatic handling. */
170
195
  readonly code: BuildErrorCode;
196
+ /** Additional context for debugging (e.g. entry name, file path). */
171
197
  readonly context: Record<string, unknown>;
198
+ /**
199
+ * @param code - Machine-readable error code
200
+ * @param message - Human-readable error description
201
+ * @param context - Optional key-value pairs for debugging context
202
+ */
172
203
  constructor(code: BuildErrorCode, message: string, context?: Record<string, unknown>);
173
204
  }
174
205
 
package/dist/index.js CHANGED
@@ -7,8 +7,15 @@ var BuildErrorCode = /* @__PURE__ */ ((BuildErrorCode2) => {
7
7
  return BuildErrorCode2;
8
8
  })(BuildErrorCode || {});
9
9
  var BuildError = class _BuildError extends Error {
10
+ /** Machine-readable error code for programmatic handling. */
10
11
  code;
12
+ /** Additional context for debugging (e.g. entry name, file path). */
11
13
  context;
14
+ /**
15
+ * @param code - Machine-readable error code
16
+ * @param message - Human-readable error description
17
+ * @param context - Optional key-value pairs for debugging context
18
+ */
12
19
  constructor(code, message, context2 = {}) {
13
20
  super(`[${code}] ${message}`);
14
21
  this.name = "BuildError";
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":["/**\n * @xrmforge/devkit - Build Error Types\n *\n * Structured error types for build operations.\n */\n\nexport enum BuildErrorCode {\n /** Build configuration is invalid or missing required fields */\n CONFIG_INVALID = 'BUILD_6001',\n /** Entry point file not found on disk */\n ENTRY_NOT_FOUND = 'BUILD_6002',\n /** esbuild compilation failed (syntax errors, missing imports) */\n BUILD_FAILED = 'BUILD_6003',\n /** Error in watch mode */\n WATCH_ERROR = 'BUILD_6004',\n}\n\n/**\n * Error class for build operations.\n * Carries a machine-readable code and optional context for debugging.\n */\nexport class BuildError extends Error {\n public readonly code: BuildErrorCode;\n public readonly context: Record<string, unknown>;\n\n constructor(code: BuildErrorCode, message: string, context: Record<string, unknown> = {}) {\n super(`[${code}] ${message}`);\n this.name = 'BuildError';\n this.code = code;\n this.context = context;\n\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, BuildError);\n }\n }\n}\n","/**\n * @xrmforge/devkit - Build Configuration\n *\n * Types and validation for the `build` section in xrmforge.config.json.\n */\n\nimport { BuildError, BuildErrorCode } from './errors.js';\n\n/** A single build entry (one WebResource) */\nexport interface BuildEntry {\n /** Relative path to the TypeScript source file */\n input: string;\n /** Global namespace for D365 form event binding (e.g. \"Contoso.Account\") */\n namespace: string;\n /** Optional output filename relative to outDir (defaults to entry key + \".js\") */\n out?: string;\n}\n\n/** Build configuration for WebResource bundling */\nexport interface BuildConfig {\n /** Bundler to use (currently only \"esbuild\") */\n bundler?: 'esbuild';\n /** Named build entries: key = entry name, value = entry config */\n entries: Record<string, BuildEntry>;\n /** Output directory for built bundles (default: \"./dist\") */\n outDir?: string;\n /** JavaScript target version (default: \"es2020\") */\n target?: string;\n /** Generate source maps (default: true) */\n sourcemap?: boolean;\n /** Minify output (default: false) */\n minify?: boolean;\n /** Additional modules to exclude from bundling */\n external?: string[];\n}\n\n/** Fully resolved build config with all defaults applied */\nexport interface ResolvedBuildConfig {\n bundler: 'esbuild';\n entries: Record<string, BuildEntry>;\n outDir: string;\n target: string;\n sourcemap: boolean;\n minify: boolean;\n external: string[];\n}\n\n/**\n * Validate a raw build config object.\n * Throws BuildError with CONFIG_INVALID if any required field is missing or invalid.\n */\nexport function validateBuildConfig(raw: unknown): BuildConfig {\n if (!raw || typeof raw !== 'object') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration must be an object.',\n );\n }\n\n const config = raw as Record<string, unknown>;\n\n // entries: required, non-empty object\n if (!config['entries'] || typeof config['entries'] !== 'object' || Array.isArray(config['entries'])) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration requires an \"entries\" object with at least one entry.',\n );\n }\n\n const entries = config['entries'] as Record<string, unknown>;\n const entryNames = Object.keys(entries);\n\n if (entryNames.length === 0) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration requires at least one entry in \"entries\".',\n );\n }\n\n for (const name of entryNames) {\n const entry = entries[name] as Record<string, unknown> | undefined;\n\n if (!entry || typeof entry !== 'object') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" must be an object with \"input\" and \"namespace\".`,\n { entry: name },\n );\n }\n\n if (!entry['input'] || typeof entry['input'] !== 'string') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" requires an \"input\" field (path to .ts source file).`,\n { entry: name },\n );\n }\n\n if (!entry['namespace'] || typeof entry['namespace'] !== 'string') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" requires a \"namespace\" field (e.g. \"Contoso.Account\").`,\n { entry: name },\n );\n }\n }\n\n // bundler: optional, must be \"esbuild\" if set\n if (config['bundler'] !== undefined && config['bundler'] !== 'esbuild') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Unsupported bundler: \"${String(config['bundler'])}\". Currently only \"esbuild\" is supported.`,\n { bundler: config['bundler'] },\n );\n }\n\n return config as unknown as BuildConfig;\n}\n\n/**\n * Apply default values to a validated build config.\n */\nexport function resolveBuildConfig(config: BuildConfig): ResolvedBuildConfig {\n return {\n bundler: config.bundler ?? 'esbuild',\n entries: config.entries,\n outDir: config.outDir ?? './dist',\n target: config.target ?? 'es2020',\n sourcemap: config.sourcemap ?? true,\n minify: config.minify ?? false,\n external: config.external ?? [],\n };\n}\n","/**\n * @xrmforge/devkit - esbuild Builder\n *\n * Builds D365 WebResources as IIFE bundles with named globals.\n * Abstracts esbuild so users never write esbuild config.\n */\n\nimport * as esbuild from 'esbuild';\nimport { stat, mkdir } from 'node:fs/promises';\nimport { resolve, dirname } from 'node:path';\nimport type { BuildConfig } from '../config.js';\nimport { resolveBuildConfig } from '../config.js';\nimport { BuildError, BuildErrorCode } from '../errors.js';\nimport type { BuildResult, BuildResultEntry } from './types.js';\n\n/**\n * Build all entries defined in the config as IIFE bundles.\n *\n * @param config - Validated build configuration\n * @param cwd - Working directory for resolving relative paths (defaults to process.cwd())\n * @returns Build result with per-entry details\n */\nexport async function build(config: BuildConfig, cwd?: string): Promise<BuildResult> {\n const startTime = Date.now();\n const resolved = resolveBuildConfig(config);\n const basedir = cwd ?? process.cwd();\n const outDir = resolve(basedir, resolved.outDir);\n\n // Ensure output directory exists\n await mkdir(outDir, { recursive: true });\n\n const entryNames = Object.keys(resolved.entries);\n const results: BuildResultEntry[] = [];\n const errors: string[] = [];\n const warnings: string[] = [];\n\n // Build all entries in parallel\n const settled = await Promise.allSettled(\n entryNames.map(async (name) => {\n const entry = resolved.entries[name]!;\n const entryStart = Date.now();\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\n\n // Ensure subdirectory exists for custom out paths\n await mkdir(dirname(outFile), { recursive: true });\n\n const buildOptions: esbuild.BuildOptions = {\n entryPoints: [resolve(basedir, entry.input)],\n bundle: true,\n format: 'iife',\n globalName: entry.namespace,\n outfile: outFile,\n target: [resolved.target],\n minify: resolved.minify,\n sourcemap: resolved.sourcemap,\n treeShaking: true,\n logLevel: 'silent',\n external: resolved.external,\n };\n\n const result = await esbuild.build(buildOptions);\n\n // Collect esbuild warnings\n for (const w of result.warnings) {\n warnings.push(`[${name}] ${w.text}`);\n }\n\n // Get output file size\n const stats = await stat(outFile);\n\n return {\n name,\n outFile,\n sizeBytes: stats.size,\n durationMs: Date.now() - entryStart,\n } satisfies BuildResultEntry;\n }),\n );\n\n for (let i = 0; i < settled.length; i++) {\n const outcome = settled[i]!;\n const name = entryNames[i]!;\n\n if (outcome.status === 'fulfilled') {\n results.push(outcome.value);\n } else {\n const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);\n // Distinguish \"file not found\" from other build errors\n if (errorMsg.includes('Could not resolve') || errorMsg.includes('ENOENT')) {\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.ENTRY_NOT_FOUND, errorMsg, { entry: name }).message}`);\n } else {\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.BUILD_FAILED, errorMsg, { entry: name }).message}`);\n }\n }\n }\n\n return {\n entries: results,\n totalDurationMs: Date.now() - startTime,\n errors,\n warnings,\n };\n}\n\n/**\n * Start watch mode for all entries.\n * Returns a dispose function to stop watching.\n *\n * @param config - Validated build configuration\n * @param options - Watch options\n * @returns Object with dispose() to stop watching\n */\nexport async function watch(\n config: BuildConfig,\n options?: {\n cwd?: string;\n onRebuild?: (result: BuildResult) => void;\n },\n): Promise<{ dispose: () => Promise<void> }> {\n const resolved = resolveBuildConfig(config);\n const basedir = options?.cwd ?? process.cwd();\n const outDir = resolve(basedir, resolved.outDir);\n\n await mkdir(outDir, { recursive: true });\n\n const contexts: esbuild.BuildContext[] = [];\n\n for (const [name, entry] of Object.entries(resolved.entries)) {\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\n await mkdir(dirname(outFile), { recursive: true });\n\n const ctx = await esbuild.context({\n entryPoints: [resolve(basedir, entry.input)],\n bundle: true,\n format: 'iife',\n globalName: entry.namespace,\n outfile: outFile,\n target: [resolved.target],\n minify: resolved.minify,\n sourcemap: resolved.sourcemap,\n treeShaking: true,\n logLevel: 'silent',\n external: resolved.external,\n });\n\n contexts.push(ctx);\n await ctx.watch();\n }\n\n return {\n dispose: async () => {\n for (const ctx of contexts) {\n await ctx.dispose();\n }\n },\n };\n}\n","/**\n * @xrmforge/devkit - Project Scaffolding\n *\n * Generates a complete D365 form scripting project from templates.\n */\n\nimport { mkdir, writeFile, readdir, access } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { ScaffoldConfig, ScaffoldResult } from './types.js';\nimport { BuildError, BuildErrorCode } from '../errors.js';\nimport { loadTemplate } from './template-loader.js';\n\n/**\n * Scaffold a new D365 form scripting project.\n *\n * Creates a complete project structure with package.json, tsconfig,\n * xrmforge.config.json, example form script, and test file.\n *\n * @param config - Scaffold configuration\n * @returns List of created files and any warnings\n * @throws {BuildError} if target directory is not empty (unless files are only dotfiles)\n */\nexport async function scaffoldProject(config: ScaffoldConfig): Promise<ScaffoldResult> {\n const { targetDir } = config;\n const filesCreated: string[] = [];\n const warnings: string[] = [];\n\n // Ensure target directory exists\n await mkdir(targetDir, { recursive: true });\n\n // Check if directory is empty (ignore dotfiles and node_modules)\n const existing = await readdir(targetDir);\n const nonDotFiles = existing.filter((f) => !f.startsWith('.') && f !== 'node_modules');\n if (nonDotFiles.length > 0 && !config.force) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Target directory is not empty: ${targetDir}\\n` +\n `Found: ${nonDotFiles.slice(0, 5).join(', ')}${nonDotFiles.length > 5 ? '...' : ''}\\n` +\n `Use --force to scaffold anyway (existing files will be skipped).`,\n { targetDir, existingFiles: nonDotFiles },\n );\n }\n\n // Create directory structure\n const dirs = [\n 'src/forms',\n 'typings',\n 'tests/forms',\n ];\n\n for (const dir of dirs) {\n await mkdir(join(targetDir, dir), { recursive: true });\n }\n\n // Generate and write all template files\n const templates = await generateTemplates(config);\n\n for (const [relativePath, content] of templates) {\n const absolutePath = join(targetDir, relativePath);\n await mkdir(join(absolutePath, '..'), { recursive: true });\n\n // In force mode: skip files that already exist\n if (config.force) {\n try {\n await access(absolutePath);\n warnings.push(`Skipped ${relativePath} (already exists)`);\n continue;\n } catch {\n // File doesn't exist, proceed with write\n }\n }\n\n await writeFile(absolutePath, content, 'utf-8');\n filesCreated.push(relativePath);\n }\n\n return { filesCreated, warnings };\n}\n\n/**\n * Generate all template file contents.\n * Returns an array of [relativePath, content] tuples.\n */\nasync function generateTemplates(config: ScaffoldConfig): Promise<Array<[string, string]>> {\n const { projectName, prefix, namespace } = config;\n const lowerPrefix = prefix.toLowerCase();\n const namespaceVars = { namespace };\n\n return [\n ['package.json', generatePackageJson(projectName)],\n ['tsconfig.json', generateTsConfig()],\n ['xrmforge.config.json', generateXrmForgeConfig(lowerPrefix, namespace)],\n ['vitest.config.ts', await loadTemplate('vitest.config.ts')],\n ['.gitignore', await loadTemplate('gitignore')],\n ['AGENT.md', await loadTemplate('AGENT.md')],\n ['src/forms/example-form.ts', await loadTemplate('example-form.ts', namespaceVars)],\n ['typings/.gitkeep', ''],\n ['tests/forms/example-form.test.ts', await loadTemplate('example-form.test.ts', namespaceVars)],\n ['.github/workflows/ci.yml', await loadTemplate('github-actions-ci.yml')],\n ['azure-pipelines.yml', await loadTemplate('azure-pipelines.yml')],\n ];\n}\n\nfunction generatePackageJson(projectName: string): string {\n const pkg = {\n name: projectName,\n version: '0.1.0',\n private: true,\n type: 'module',\n scripts: {\n generate: 'xrmforge generate',\n typecheck: 'tsc --noEmit',\n build: 'xrmforge build',\n watch: 'xrmforge build --watch',\n test: 'vitest run',\n 'test:watch': 'vitest',\n },\n devDependencies: {\n '@types/xrm': '^9.0.90',\n '@xrmforge/cli': '^0.4.3',\n '@xrmforge/testing': '^0.2.0',\n '@xrmforge/helpers': '^0.1.0',\n typescript: '^5.7.0',\n vitest: '^3.0.0',\n },\n };\n return JSON.stringify(pkg, null, 2) + '\\n';\n}\n\nfunction generateTsConfig(): string {\n const config = {\n compilerOptions: {\n target: 'ES2020',\n module: 'ESNext',\n moduleResolution: 'bundler',\n lib: ['ES2020', 'DOM'],\n types: ['xrm'],\n strict: true,\n noEmit: true,\n skipLibCheck: false,\n esModuleInterop: true,\n },\n include: [\n 'src/**/*.ts',\n 'typings/**/*.d.ts',\n 'typings/**/*.ts',\n ],\n };\n return JSON.stringify(config, null, 2) + '\\n';\n}\n\nfunction generateXrmForgeConfig(prefix: string, namespace: string): string {\n const config = {\n build: {\n outDir: `./dist/${prefix}_/JS`,\n target: 'es2020',\n sourcemap: true,\n minify: true,\n entries: {\n example_form: {\n input: './src/forms/example-form.ts',\n namespace: `${namespace}.Example`,\n out: 'Example/OnLoad.js',\n },\n },\n },\n };\n return JSON.stringify(config, null, 2) + '\\n';\n}\n","/**\n * Template loader for scaffold templates.\n *\n * Reads template files from the templates/ directory relative to this module.\n * Supports {{placeholder}} variable substitution.\n */\n\nimport { readFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\n/** Path to the templates directory (relative to compiled output or source). */\nconst TEMPLATES_DIR = join(__dirname, 'templates');\n\n/**\n * Load a template file by name and optionally substitute variables.\n *\n * Variables in the template use the `{{key}}` syntax.\n *\n * @param name - Template filename (e.g. 'AGENT.md', 'example-form.ts')\n * @param vars - Optional key-value pairs for placeholder substitution\n * @returns Template content with variables replaced\n */\nexport async function loadTemplate(\n name: string,\n vars?: Record<string, string>,\n): Promise<string> {\n const content = await readFile(join(TEMPLATES_DIR, name), 'utf-8');\n\n if (!vars || Object.keys(vars).length === 0) {\n return content;\n }\n\n return Object.entries(vars).reduce(\n (result, [key, value]) => result.replaceAll(`{{${key}}}`, value),\n content,\n );\n}\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;AAeL,IAAM,aAAN,MAAM,oBAAmB,MAAM;AAAA,EACpB;AAAA,EACA;AAAA,EAEhB,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;;;ACgBO,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;AAKO,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;;;AC7HA,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,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;AAMA,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,YAAY,MAAM,aAAa,UAAU,CAAC;AAAA,IAC3C,CAAC,6BAA6B,MAAM,aAAa,mBAAmB,aAAa,CAAC;AAAA,IAClF,CAAC,oBAAoB,EAAE;AAAA,IACvB,CAAC,oCAAoC,MAAM,aAAa,wBAAwB,aAAa,CAAC;AAAA,IAC9F,CAAC,4BAA4B,MAAM,aAAa,uBAAuB,CAAC;AAAA,IACxE,CAAC,uBAAuB,MAAM,aAAa,qBAAqB,CAAC;AAAA,EACnE;AACF;AAEA,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,IAChB;AAAA,IACA,iBAAiB;AAAA,MACf,cAAc;AAAA,MACd,iBAAiB;AAAA,MACjB,qBAAqB;AAAA,MACrB,qBAAqB;AAAA,MACrB,YAAY;AAAA,MACZ,QAAQ;AAAA,IACV;AAAA,EACF;AACA,SAAO,KAAK,UAAU,KAAK,MAAM,CAAC,IAAI;AACxC;AAEA,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,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO,KAAK,UAAU,QAAQ,MAAM,CAAC,IAAI;AAC3C;AAEA,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 'typings',\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 ['AGENT.md', await loadTemplate('AGENT.md')],\r\n ['src/forms/example-form.ts', await loadTemplate('example-form.ts', namespaceVars)],\r\n ['typings/.gitkeep', ''],\r\n ['tests/forms/example-form.test.ts', await loadTemplate('example-form.test.ts', namespaceVars)],\r\n ['.github/workflows/ci.yml', await loadTemplate('github-actions-ci.yml')],\r\n ['azure-pipelines.yml', await loadTemplate('azure-pipelines.yml')],\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 },\r\n devDependencies: {\r\n '@types/xrm': '^9.0.90',\r\n '@xrmforge/cli': '^0.4.3',\r\n '@xrmforge/testing': '^0.2.0',\r\n '@xrmforge/helpers': '^0.1.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 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 'typings/**/*.d.ts',\r\n 'typings/**/*.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"]}
@@ -1,189 +1,357 @@
1
- # XrmForge - AI Agent Instructions
2
-
3
- This file helps AI coding assistants write optimal Dynamics 365 form scripts.
4
-
5
- ## Packages
6
-
7
- - `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata
8
- - `@xrmforge/helpers` - Browser-safe runtime: select(), parseLookup(), typedForm(), Xrm constants, Action executors
9
- - `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange()
10
- - `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
11
- - `@xrmforge/eslint-plugin` - D365-specific ESLint rules
12
-
13
- ## Generated Types (generated/ directory)
14
-
15
- Run `xrmforge generate` to create:
16
- - `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
17
- - `generated/optionsets/{entity}.ts` - OptionSet const enums
18
- - `generated/entities/{entity}.ts` - Entity interface
19
- - `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
20
- - `generated/entity-names.ts` - EntityNames const enum
21
- - `generated/index.ts` - Barrel file with `export * from` re-exports
22
-
23
- ## Rules: Always
24
-
25
- 1. **Fields Enum** for getAttribute/getControl (not raw strings):
26
- `form.getAttribute(Fields.AccountName)` not `form.getAttribute("name")`
27
-
28
- 2. **OptionSet Enum** for comparisons (not magic numbers):
29
- `status === StatusCode.Active` not `status === 0`
30
-
31
- 3. **Cast formContext** to generated form interface:
32
- `const form = ctx.getFormContext() as AccountMainForm;`
33
-
34
- 4. **EntityNames Enum** for Web API calls:
35
- `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)`
36
-
37
- 5. **parseLookup()** from @xrmforge/helpers for lookup values
38
-
39
- 6. **select()** from @xrmforge/helpers for $select queries
40
-
41
- 7. **createFormMock()** from @xrmforge/testing for tests
42
-
43
- 8. **Module exports** (not window/global assignments). esbuild globalName handles namespacing.
44
-
45
- 9. **Tabs/Sections/Subgrids Enums** for UI access
46
-
47
- 10. **Error handling** in all async event handlers (try/catch)
48
-
49
- ## Rules: Never
50
-
51
- - Never `getAttribute("raw_string")` when Fields enum exists
52
- - Never magic numbers for OptionSet values
53
- - Never `Xrm.Page` (deprecated since D365 v9.0)
54
- - Never synchronous XMLHttpRequest
55
- - Never `eval()`
56
- - Never `window.X = ...` (use module exports)
57
-
58
- ## Before/After Examples
59
-
60
- ### Field Access
61
- ```typescript
62
- // BEFORE: formContext.getAttribute("name").getValue()
63
- // AFTER:
64
- import { AccountMainFormFieldsEnum as Fields } from '../generated/forms/account.js';
65
- import type { AccountMainForm } from '../generated/forms/account.js';
66
- const form = ctx.getFormContext() as AccountMainForm;
67
- form.getAttribute(Fields.AccountName).getValue(); // StringAttribute, typed
68
- ```
69
-
70
- ### OptionSet Comparison
71
- ```typescript
72
- // BEFORE: if (status.getValue() === 595300002) { ... }
73
- // AFTER:
74
- import { StatusCode } from '../generated/optionsets/invoice.js';
75
- if (status.getValue() === StatusCode.Gebucht) { ... }
76
- ```
77
-
78
- ### Testing
79
- ```typescript
80
- import { createFormMock } from '@xrmforge/testing';
81
- const mock = createFormMock<AccountMainForm>({
82
- name: 'Test', statuscode: 0
83
- });
84
- onLoad(mock.executionContext);
85
- expect(mock.formContext.getControl('revenue').getVisible()).toBe(true);
86
- ```
87
-
88
- ## File Structure
89
-
90
- ```
91
- src/forms/{entity}-form.ts - Form scripts (one per entity)
92
- src/shared/{name}.ts - Shared utilities
93
- generated/ - Generated types (do not edit manually)
94
- tests/forms/{entity}.test.ts - Tests
95
- xrmforge.config.json - Build config
96
- ```
97
-
98
- ## Pattern Recognition: Legacy to XrmForge
99
-
100
- When you see these patterns in legacy code, apply the XrmForge replacement:
101
-
102
- | Legacy Pattern | XrmForge Replacement |
103
- |---|---|
104
- | `getAttribute("name")` | `getAttribute(Fields.Name)` |
105
- | `getControl("name")` | `getControl(Fields.Name)` |
106
- | `getValue() === 595300000` | `getValue() === OptionSets.StatusCode.Active` |
107
- | `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
108
- | `"?$select=name,revenue"` | `select(Fields.Name, Fields.Revenue)` (from @xrmforge/helpers) |
109
- | `value[0].id.replace("{","")...` | `parseLookup(form.getAttribute(Fields.X))` (from @xrmforge/helpers) |
110
- | `Xrm.Page.getAttribute(...)` | `formContext.getAttribute(...)` |
111
- | `var formContext` (global) | `const form = ctx.getFormContext()` (parameter) |
112
- | `function form_OnLoad(ctx)` | `export function onLoad(ctx: Xrm.Events.EventContext)` |
113
- | `.then(success, error)` | `async/await with try/catch` |
114
-
115
- ### Creating OptionSet Enums from Legacy Magic Numbers
116
-
117
- When you find magic numbers like `getValue() === 105710002` in legacy code:
118
- 1. Search the file for ALL numeric comparisons with getValue()
119
- 2. Create a const enum in generated/optionsets/ with descriptive names
120
- 3. Import and use the enum instead of the number
121
-
122
- Example:
123
- ```typescript
124
- // generated/optionsets/invoice.ts
125
- export const enum InvoiceStatusCode {
126
- Neu = 1,
127
- Versendet = 105710000,
128
- Abgeschlossen = 105710001,
129
- Gebucht = 105710002,
130
- }
131
-
132
- // In the form script:
133
- import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
134
- if (status.getValue() === InvoiceStatusCode.Gebucht) { ... }
135
- ```
136
-
137
- ## Testing with Global Xrm Mock
138
-
139
- Use `setupXrmMock()` from @xrmforge/testing to mock the global Xrm namespace:
140
- ```typescript
141
- import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
142
-
143
- beforeEach(() => setupXrmMock());
144
- afterEach(() => teardownXrmMock());
145
-
146
- // Override specific WebApi methods:
147
- setupXrmMock({
148
- webApiOverrides: {
149
- retrieveMultipleRecords: async () => ({ entities: [{ name: 'Test' }] }),
150
- },
151
- });
152
- ```
153
-
154
- ## Build
155
-
156
- ```bash
157
- npx xrmforge build # IIFE bundles for D365
158
- npx xrmforge build --watch # Watch mode (~10ms rebuilds)
159
- ```
160
-
161
- ## @types/xrm Pitfalls (known issues)
162
-
163
- When creating manual typings without `xrmforge generate`:
164
-
165
- 1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext` (getAttribute overload conflicts).
166
- Use `Omit` pattern instead:
167
- ```typescript
168
- interface AccountMainForm extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {
169
- getAttribute(name: Fields.AccountName): Xrm.Attributes.StringAttribute;
170
- getAttribute(name: string): Xrm.Attributes.Attribute;
171
- // ...
172
- }
173
- ```
174
-
175
- 2. **AlertDialogResponse** does NOT exist in @types/xrm. Use `Xrm.Async.PromiseLike<void>`.
176
-
177
- 3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
178
-
179
- 4. **setNotification()** requires 2 arguments: (message, uniqueId).
180
-
181
- 5. **openFile()** requires `fileSize` property in FileDetails.
182
-
183
- 6. **const enum in .d.ts files** cannot be imported at runtime by test frameworks.
184
- Since v0.8.0, XrmForge generates `.ts` files, so this is no longer an issue.
185
- For manual typings, use regular `enum` in `.ts` files (not `.d.ts`).
186
-
187
- ## Full Migration Guide
188
-
189
- See: https://www.npmjs.com/package/@xrmforge/typegen (MIGRATION.md)
1
+ # XrmForge - AI Agent Instructions
2
+
3
+ This file helps AI coding assistants write optimal Dynamics 365 form scripts.
4
+
5
+ ## Packages
6
+
7
+ - `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata
8
+ - `@xrmforge/helpers` - Browser-safe runtime: select(), parseLookup(), typedForm(), Xrm constants, Action executors
9
+ - `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange()
10
+ - `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
11
+ - `@xrmforge/eslint-plugin` - D365-specific ESLint rules
12
+
13
+ ## Generated Types (generated/ directory)
14
+
15
+ Run `xrmforge generate` to create:
16
+ - `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
17
+ - `generated/optionsets/{entity}.ts` - OptionSet const enums
18
+ - `generated/entities/{entity}.ts` - Entity interface
19
+ - `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
20
+ - `generated/entity-names.ts` - EntityNames const enum
21
+ - `generated/index.ts` - Barrel file with `export * from` re-exports
22
+
23
+ ## Rules: MANDATORY (every violation is a bug)
24
+
25
+ 1. **Fields Enum** for ALL getAttribute/getControl calls. Never raw strings.
26
+ ```typescript
27
+ import { AccountMainFormFieldsEnum as Fields } from '../generated/forms/account.js';
28
+ form.getAttribute(Fields.Name) // CORRECT
29
+ form.getAttribute("name") // BUG - raw string
30
+ ```
31
+
32
+ 2. **OptionSet Enum** for ALL value comparisons. Never magic numbers.
33
+ ```typescript
34
+ import { StatusCode } from '../generated/optionsets/invoice.js';
35
+ if (status === StatusCode.Active) // CORRECT
36
+ if (status === 0) // BUG - magic number
37
+ ```
38
+
39
+ 3. **FormContext Cast** to generated form interface in every onLoad:
40
+ ```typescript
41
+ import type { AccountMainForm } from '../generated/forms/account.js';
42
+ const form = ctx.getFormContext() as AccountMainForm;
43
+ ```
44
+
45
+ 4. **EntityNames Enum** in ALL Xrm.WebApi calls:
46
+ ```typescript
47
+ import { EntityNames } from '../generated/entity-names.js';
48
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id)
49
+ ```
50
+
51
+ 5. **Lookup helpers** from @xrmforge/helpers for ALL lookup value access:
52
+ ```typescript
53
+ import { formLookup, formLookupId, parseLookup } from '@xrmforge/helpers';
54
+ // Form lookups (getAttribute on FormContext):
55
+ const customer = formLookup(form.getAttribute(Fields.CustomerId));
56
+ const customerId = formLookupId(form.getAttribute(Fields.CustomerId));
57
+ // Web API response lookups (_fieldname_value + OData annotations):
58
+ const parent = parseLookup(apiResponse, 'parentaccountid');
59
+ ```
60
+
61
+ 6. **select()** from @xrmforge/helpers for ALL $select queries:
62
+ ```typescript
63
+ import { select } from '@xrmforge/helpers';
64
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id, select(Fields.Name, Fields.Revenue))
65
+ ```
66
+
67
+ 7. **wrapHandler()** around EVERY exported async event handler:
68
+ ```typescript
69
+ import { createLogger } from '../shared/logger';
70
+ import { wrapHandler } from '../shared/error-handler';
71
+ const logger = createLogger('Namespace.Entity');
72
+ export const onLoad = wrapHandler('Namespace.Entity.onLoad', logger, async (ctx) => {
73
+ // handler code
74
+ });
75
+ ```
76
+
77
+ 8. **createFormMock()** from @xrmforge/testing for ALL form tests:
78
+ ```typescript
79
+ import { createFormMock, fireOnChange, setupXrmMock } from '@xrmforge/testing';
80
+ ```
81
+
82
+ 9. **Module exports** (not window/global assignments). esbuild globalName handles namespacing.
83
+
84
+ 10. **Structured Logger** instead of console.* (except in logger.ts itself):
85
+ ```typescript
86
+ import { createLogger } from '../shared/logger';
87
+ const logger = createLogger('Namespace.Entity');
88
+ logger.info('Form loaded', { recordId });
89
+ ```
90
+
91
+ ## Rules: NEVER (every occurrence is a bug)
92
+
93
+ - Never `getAttribute("raw_string")` when Fields enum exists
94
+ - Never magic numbers for OptionSet values (use OptionSet enums)
95
+ - Never `Xrm.Page` (deprecated since D365 v9.0)
96
+ - Never synchronous XMLHttpRequest
97
+ - Never `eval()`
98
+ - Never `window.X = ...` (use module exports)
99
+ - Never `console.log/warn/error` in form scripts (use shared logger)
100
+ - Never export async handlers without wrapHandler()
101
+ - Never `Xrm.WebApi.retrieveRecord("account", ...)` with raw entity name (use EntityNames)
102
+ - Never `"?$select=name,revenue"` as raw string (use select() from @xrmforge/helpers)
103
+ - Never `.getValue()[0].id.replace(...)` for lookups (use formLookup/formLookupId from @xrmforge/helpers)
104
+ - Never `import ... from '@xrmforge/typegen'` in browser code. @xrmforge/typegen is a Node.js CLI tool. Use `@xrmforge/helpers` for browser-safe runtime functions (select, parseLookup, formLookup, createUnboundAction, etc.)
105
+
106
+ ## Mandatory Shared Utilities
107
+
108
+ Every XrmForge project MUST have these in `src/shared/`:
109
+
110
+ ### logger.ts
111
+ ```typescript
112
+ export interface Logger { debug(msg: string, data?: unknown): void; info(...); warn(...); error(...); }
113
+ export function createLogger(namespace: string): Logger;
114
+ // Only file allowed to use console.*
115
+ ```
116
+
117
+ ### error-handler.ts
118
+ ```typescript
119
+ export function wrapHandler<T>(name: string, logger: Logger, handler: T): T;
120
+ // Catches sync+async errors, shows form notification, never rethrows
121
+ ```
122
+
123
+ ### constants.ts
124
+ ```typescript
125
+ export const NOTIFICATION_IDS = { ... } as const;
126
+ export const MESSAGES = { ... } as const;
127
+ ```
128
+
129
+ ## Before/After Examples
130
+
131
+ ### Field Access
132
+ ```typescript
133
+ // BEFORE: formContext.getAttribute("name").getValue()
134
+ // AFTER:
135
+ import { AccountMainFormFieldsEnum as Fields } from '../generated/forms/account.js';
136
+ import type { AccountMainForm } from '../generated/forms/account.js';
137
+ const form = ctx.getFormContext() as AccountMainForm;
138
+ form.getAttribute(Fields.AccountName).getValue(); // StringAttribute, typed
139
+ ```
140
+
141
+ ### OptionSet Comparison
142
+ ```typescript
143
+ // BEFORE: if (status.getValue() === 595300002) { ... }
144
+ // AFTER:
145
+ import { StatusCode } from '../generated/optionsets/invoice.js';
146
+ if (status.getValue() === StatusCode.Gebucht) { ... }
147
+ ```
148
+
149
+ ### Testing
150
+ ```typescript
151
+ import { createFormMock } from '@xrmforge/testing';
152
+ const mock = createFormMock<AccountMainForm>({
153
+ name: 'Test', statuscode: 0
154
+ });
155
+ onLoad(mock.asEventContext());
156
+ expect(mock.formContext.getControl('revenue').getVisible()).toBe(true);
157
+ ```
158
+
159
+ ## File Structure
160
+
161
+ ```
162
+ src/forms/{entity}-form.ts - Form scripts (one per entity)
163
+ src/shared/{name}.ts - Shared utilities
164
+ generated/ - Generated types (do not edit manually)
165
+ tests/forms/{entity}.test.ts - Tests
166
+ xrmforge.config.json - Build config
167
+ ```
168
+
169
+ ## Pattern Recognition: Legacy to XrmForge
170
+
171
+ When you see these patterns in legacy code, apply the XrmForge replacement:
172
+
173
+ | Legacy Pattern | XrmForge Replacement |
174
+ |---|---|
175
+ | `getAttribute("name")` | `getAttribute(Fields.Name)` |
176
+ | `getControl("name")` | `getControl(Fields.Name)` |
177
+ | `getValue() === 595300000` | `getValue() === OptionSets.StatusCode.Active` |
178
+ | `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
179
+ | `"?$select=name,revenue"` | `select(Fields.Name, Fields.Revenue)` (from @xrmforge/helpers) |
180
+ | `value[0].id.replace("{","")...` | `parseLookup(form.getAttribute(Fields.X))` (from @xrmforge/helpers) |
181
+ | `Xrm.Page.getAttribute(...)` | `formContext.getAttribute(...)` |
182
+ | `var formContext` (global) | `const form = ctx.getFormContext()` (parameter) |
183
+ | `function form_OnLoad(ctx)` | `export function onLoad(ctx: Xrm.Events.EventContext)` |
184
+ | `.then(success, error)` | `async/await with try/catch` |
185
+
186
+ ### Creating OptionSet Enums from Legacy Magic Numbers
187
+
188
+ When you find magic numbers like `getValue() === 105710002` in legacy code:
189
+ 1. Search the file for ALL numeric comparisons with getValue()
190
+ 2. Create a const enum in generated/optionsets/ with descriptive names
191
+ 3. Import and use the enum instead of the number
192
+
193
+ Example:
194
+ ```typescript
195
+ // generated/optionsets/invoice.ts
196
+ export const enum InvoiceStatusCode {
197
+ Neu = 1,
198
+ Versendet = 105710000,
199
+ Abgeschlossen = 105710001,
200
+ Gebucht = 105710002,
201
+ }
202
+
203
+ // In the form script:
204
+ import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
205
+ if (status.getValue() === InvoiceStatusCode.Gebucht) { ... }
206
+ ```
207
+
208
+ ## Testing with Global Xrm Mock
209
+
210
+ Use `setupXrmMock()` from @xrmforge/testing to mock the global Xrm namespace:
211
+ ```typescript
212
+ import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
213
+
214
+ beforeEach(() => setupXrmMock());
215
+ afterEach(() => teardownXrmMock());
216
+
217
+ // Override specific WebApi methods:
218
+ setupXrmMock({
219
+ webApiOverrides: {
220
+ retrieveMultipleRecords: async () => ({ entities: [{ name: 'Test' }] }),
221
+ },
222
+ });
223
+ ```
224
+
225
+ ## Build
226
+
227
+ ```bash
228
+ npx xrmforge build # IIFE bundles for D365
229
+ npx xrmforge build --watch # Watch mode (~10ms rebuilds)
230
+ ```
231
+
232
+ ## @types/xrm Pitfalls (known issues)
233
+
234
+ When creating manual typings without `xrmforge generate`:
235
+
236
+ 1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext` (getAttribute overload conflicts).
237
+ Use `Omit` pattern instead:
238
+ ```typescript
239
+ interface AccountMainForm extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {
240
+ getAttribute(name: Fields.AccountName): Xrm.Attributes.StringAttribute;
241
+ getAttribute(name: string): Xrm.Attributes.Attribute;
242
+ // ...
243
+ }
244
+ ```
245
+
246
+ 2. **AlertDialogResponse** does NOT exist in @types/xrm. Use `Xrm.Async.PromiseLike<void>`.
247
+
248
+ 3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
249
+
250
+ 4. **setNotification()** requires 2 arguments: (message, uniqueId).
251
+
252
+ 5. **openFile()** requires `fileSize` property in FileDetails.
253
+
254
+ 6. **const enum in .d.ts files** cannot be imported at runtime by test frameworks.
255
+ Since v0.8.0, XrmForge generates `.ts` files, so this is no longer an issue.
256
+ For manual typings, use regular `enum` in `.ts` files (not `.d.ts`).
257
+
258
+ ## Self-Check (MANDATORY before Tests)
259
+
260
+ After converting ALL scripts, run these checks. Fix every violation before proceeding to tests.
261
+ Document results in SESSION-GEDAECHTNIS.md (violation count per category).
262
+
263
+ ### Pattern Compliance (all must be 0, or documented exception)
264
+
265
+ ```bash
266
+ # 1. Raw field strings in getAttribute/getControl (must use Fields Enum)
267
+ grep -rn "getAttribute('" src/forms/ --include="*.ts" | grep -v "Fields\."
268
+ grep -rn "getControl('" src/forms/ --include="*.ts" | grep -v "Fields\."
269
+
270
+ # 2. Magic numbers in OptionSet comparisons (must use OptionSet Enum)
271
+ grep -rn "getValue() ===" src/ --include="*.ts" | grep -E "[0-9]{3,}"
272
+
273
+ # 3. Direct _value access instead of parseLookup (in Web API responses)
274
+ grep -rn "_value\b" src/ --include="*.ts" | grep -v "generated/" | grep -v "parseLookup" | grep -v "getValue"
275
+
276
+ # 4. Raw entity names in WebApi calls (must use EntityNames)
277
+ grep -rn "retrieveRecord\|retrieveMultipleRecords\|deleteRecord\|createRecord\|updateRecord" src/ --include="*.ts" | grep "'[a-z]" | grep -v "EntityNames"
278
+
279
+ # 5. Missing select() in retrieveRecord (no raw "$select=" strings)
280
+ grep -rn "retrieveRecord\|retrieveMultipleRecords" src/ --include="*.ts" | grep "\$select" | grep -v "select("
281
+
282
+ # 6. Missing FormContext Cast in onLoad (must have "as <Generated>Form")
283
+ grep -rn "getFormContext()" src/forms/ --include="*.ts" | grep -v " as "
284
+
285
+ # 7. Exported handlers without wrapHandler
286
+ grep -rn "^export const\|^export async function\|^export function" src/forms/ --include="*.ts" | grep -v "wrapHandler"
287
+
288
+ # 8. Entity-level FieldsEnums not used (generated/fields/ should be imported)
289
+ echo "Fields imports from generated/fields/:"
290
+ grep -rn "from.*generated/fields/" src/ --include="*.ts" | wc -l
291
+ ```
292
+
293
+ ### Code Quality (all must be 0)
294
+
295
+ ```bash
296
+ # console.* outside logger.ts
297
+ grep -rn "console\." src/ --include="*.ts" | grep -v "logger.ts"
298
+
299
+ # Xrm.Page (deprecated since D365 v9.0)
300
+ grep -rn "Xrm\.Page" src/ --include="*.ts"
301
+
302
+ # var declarations
303
+ grep -rnE "^\s*var " src/ --include="*.ts"
304
+
305
+ # eval()
306
+ grep -rn "\beval(" src/ --include="*.ts"
307
+
308
+ # XMLHttpRequest
309
+ grep -rn "XMLHttpRequest" src/ --include="*.ts"
310
+
311
+ # as any without eslint-disable comment explaining why
312
+ grep -rn "as any" src/ --include="*.ts" | grep -v "eslint-disable"
313
+ ```
314
+
315
+ ### Documentation (all must pass)
316
+
317
+ ```bash
318
+ # Files without JSDoc header (first line must be /**)
319
+ for f in src/forms/*.ts src/shared/*.ts; do
320
+ head -1 "$f" | grep -q "^/\*\*" || echo "No header: $f"
321
+ done
322
+
323
+ # Exported functions without JSDoc
324
+ grep -rn -B1 "^export " src/ --include="*.ts" | grep -E "^[^*]*export" | grep -v "/\*\*"
325
+ ```
326
+
327
+ ### Test Completeness
328
+
329
+ ```bash
330
+ # Every form script needs a test file
331
+ for f in src/forms/*.ts; do
332
+ base=$(basename "$f" .ts)
333
+ test -f "tests/forms/${base}.test.ts" || echo "No test: $f"
334
+ done
335
+
336
+ # Every test file must use setupXrmMock
337
+ for f in tests/**/*.test.ts; do
338
+ grep -q "setupXrmMock" "$f" || echo "No setupXrmMock: $f"
339
+ done
340
+
341
+ # Every test file needs at least 2 test cases
342
+ for f in tests/**/*.test.ts; do
343
+ count=$(grep -c "it(" "$f" 2>/dev/null || echo 0)
344
+ [ "$count" -lt 2 ] && echo "Only $count tests: $f"
345
+ done
346
+ ```
347
+
348
+ ### Exceptions
349
+
350
+ Some checks have legitimate exceptions:
351
+ - **Raw field strings in helpers**: Generic helper functions that accept `fieldName: string` parameters cannot use Fields Enums. Document these.
352
+ - **System entities not in EntityNames**: Entities not in the Solution (e.g. `annotation`, `transactioncurrency`, `systemuser`) may use string literals. Document which ones.
353
+ - **as any for Grid.refresh()**: `@types/xrm` does not type `Grid.refresh()`. Requires eslint-disable with explanation.
354
+
355
+ ## Full Migration Guide
356
+
357
+ See: https://www.npmjs.com/package/@xrmforge/typegen (MIGRATION.md)
@@ -1,32 +1,32 @@
1
- trigger:
2
- branches:
3
- include:
4
- - main
5
-
6
- pool:
7
- vmImage: 'ubuntu-latest'
8
-
9
- steps:
10
- - task: NodeTool@0
11
- inputs:
12
- versionSpec: '20.x'
13
- displayName: 'Install Node.js'
14
-
15
- - script: npm ci
16
- displayName: 'Install dependencies'
17
-
18
- - script: npx xrmforge generate --from-config
19
- displayName: 'Generate types from Dataverse'
20
- env:
21
- XRMFORGE_CLIENT_ID: $(XRMFORGE_CLIENT_ID)
22
- XRMFORGE_CLIENT_SECRET: $(XRMFORGE_CLIENT_SECRET)
23
- XRMFORGE_TENANT_ID: $(XRMFORGE_TENANT_ID)
24
-
25
- - script: npx tsc --noEmit
26
- displayName: 'Type check'
27
-
28
- - script: npx vitest run
29
- displayName: 'Test'
30
-
31
- - script: npx xrmforge build
32
- displayName: 'Build WebResources'
1
+ trigger:
2
+ branches:
3
+ include:
4
+ - main
5
+
6
+ pool:
7
+ vmImage: 'ubuntu-latest'
8
+
9
+ steps:
10
+ - task: NodeTool@0
11
+ inputs:
12
+ versionSpec: '20.x'
13
+ displayName: 'Install Node.js'
14
+
15
+ - script: npm ci
16
+ displayName: 'Install dependencies'
17
+
18
+ - script: npx xrmforge generate --from-config
19
+ displayName: 'Generate types from Dataverse'
20
+ env:
21
+ XRMFORGE_CLIENT_ID: $(XRMFORGE_CLIENT_ID)
22
+ XRMFORGE_CLIENT_SECRET: $(XRMFORGE_CLIENT_SECRET)
23
+ XRMFORGE_TENANT_ID: $(XRMFORGE_TENANT_ID)
24
+
25
+ - script: npx tsc --noEmit
26
+ displayName: 'Type check'
27
+
28
+ - script: npx vitest run
29
+ displayName: 'Test'
30
+
31
+ - script: npx xrmforge build
32
+ displayName: 'Build WebResources'
@@ -1,19 +1,19 @@
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. For now, this is a placeholder.
8
- */
9
- describe('{{namespace}}.Example', () => {
10
- it('should export onLoad function', 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 function', async () => {
16
- const mod = await import('../../src/forms/example-form.js');
17
- expect(typeof mod.onSave).toBe('function');
18
- });
19
- });
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. For now, this is a placeholder.
8
+ */
9
+ describe('{{namespace}}.Example', () => {
10
+ it('should export onLoad function', 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 function', async () => {
16
+ const mod = await import('../../src/forms/example-form.js');
17
+ expect(typeof mod.onSave).toBe('function');
18
+ });
19
+ });
@@ -1,40 +1,40 @@
1
- /**
2
- * Example Form Script for Dynamics 365.
3
- *
4
- * Register in D365 as: {{namespace}}.Example.onLoad
5
- *
6
- * Replace this with your actual form logic.
7
- */
8
-
9
- /**
10
- * Called when the form loads.
11
- */
12
- export function onLoad(executionContext: Xrm.Events.EventContext): void {
13
- const formContext = executionContext.getFormContext();
14
-
15
- // Example: show a notification on the form
16
- formContext.ui.setFormNotification(
17
- 'Form loaded successfully',
18
- 'INFO',
19
- 'example-notification',
20
- );
21
-
22
- // Example: read a field value
23
- // TODO: Replace with generated Fields enum after running 'xrmforge generate'
24
- // Example: formContext.getAttribute(Fields.Name)
25
- const nameAttr = formContext.getAttribute('name');
26
- if (nameAttr) {
27
- const value = nameAttr.getValue();
28
- console.log('Name field value:', value);
29
- }
30
- }
31
-
32
- /**
33
- * Called when the form is saved.
34
- */
35
- export function onSave(executionContext: Xrm.Events.EventContext): void {
36
- const formContext = executionContext.getFormContext();
37
-
38
- // Clear the notification on save
39
- formContext.ui.clearFormNotification('example-notification');
40
- }
1
+ /**
2
+ * Example Form Script for Dynamics 365.
3
+ *
4
+ * Register in D365 as: {{namespace}}.Example.onLoad
5
+ *
6
+ * Replace this with your actual form logic.
7
+ */
8
+
9
+ /**
10
+ * Called when the form loads.
11
+ */
12
+ export function onLoad(executionContext: Xrm.Events.EventContext): void {
13
+ const formContext = executionContext.getFormContext();
14
+
15
+ // Example: show a notification on the form
16
+ formContext.ui.setFormNotification(
17
+ 'Form loaded successfully',
18
+ 'INFO',
19
+ 'example-notification',
20
+ );
21
+
22
+ // Example: read a field value
23
+ // TODO: Replace with generated Fields enum after running 'xrmforge generate'
24
+ // Example: formContext.getAttribute(Fields.Name)
25
+ const nameAttr = formContext.getAttribute('name');
26
+ if (nameAttr) {
27
+ const value = nameAttr.getValue();
28
+ console.log('Name field value:', value);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Called when the form is saved.
34
+ */
35
+ export function onSave(executionContext: Xrm.Events.EventContext): void {
36
+ const formContext = executionContext.getFormContext();
37
+
38
+ // Clear the notification on save
39
+ formContext.ui.clearFormNotification('example-notification');
40
+ }
@@ -1,36 +1,36 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- build:
11
- runs-on: ubuntu-latest
12
-
13
- steps:
14
- - uses: actions/checkout@v4
15
-
16
- - uses: actions/setup-node@v4
17
- with:
18
- node-version: 20
19
-
20
- - run: npm ci
21
-
22
- - name: Generate types from Dataverse
23
- run: npx xrmforge generate --from-config
24
- env:
25
- XRMFORGE_CLIENT_ID: ${{ secrets.XRMFORGE_CLIENT_ID }}
26
- XRMFORGE_CLIENT_SECRET: ${{ secrets.XRMFORGE_CLIENT_SECRET }}
27
- XRMFORGE_TENANT_ID: ${{ secrets.XRMFORGE_TENANT_ID }}
28
-
29
- - name: Type check
30
- run: npx tsc --noEmit
31
-
32
- - name: Test
33
- run: npx vitest run
34
-
35
- - name: Build WebResources
36
- run: npx xrmforge build
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 20
19
+
20
+ - run: npm ci
21
+
22
+ - name: Generate types from Dataverse
23
+ run: npx xrmforge generate --from-config
24
+ env:
25
+ XRMFORGE_CLIENT_ID: ${{ secrets.XRMFORGE_CLIENT_ID }}
26
+ XRMFORGE_CLIENT_SECRET: ${{ secrets.XRMFORGE_CLIENT_SECRET }}
27
+ XRMFORGE_TENANT_ID: ${{ secrets.XRMFORGE_TENANT_ID }}
28
+
29
+ - name: Type check
30
+ run: npx tsc --noEmit
31
+
32
+ - name: Test
33
+ run: npx vitest run
34
+
35
+ - name: Build WebResources
36
+ run: npx xrmforge build
@@ -1,19 +1,19 @@
1
- # Dependencies
2
- node_modules/
3
-
4
- # Build output
5
- dist/
6
-
7
- # XrmForge cache
8
- .xrmforge/
9
-
10
- # IDE
11
- .vscode/settings.json
12
- .idea/
13
-
14
- # OS
15
- .DS_Store
16
- Thumbs.db
17
-
18
- # Logs
19
- *.log
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # XrmForge cache
8
+ .xrmforge/
9
+
10
+ # IDE
11
+ .vscode/settings.json
12
+ .idea/
13
+
14
+ # OS
15
+ .DS_Store
16
+ Thumbs.db
17
+
18
+ # Logs
19
+ *.log
@@ -1,8 +1,8 @@
1
- import { defineConfig } from 'vitest/config';
2
-
3
- export default defineConfig({
4
- test: {
5
- globals: false,
6
- include: ['tests/**/*.test.ts'],
7
- },
8
- });
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: false,
6
+ include: ['tests/**/*.test.ts'],
7
+ },
8
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrmforge/devkit",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Build orchestration and project tooling for Dynamics 365 WebResources",
5
5
  "keywords": [
6
6
  "dynamics-365",
@@ -25,7 +25,8 @@
25
25
  "types": "./dist/index.d.ts",
26
26
  "import": "./dist/index.js",
27
27
  "default": "./dist/index.js"
28
- }
28
+ },
29
+ "./package.json": "./package.json"
29
30
  },
30
31
  "main": "./dist/index.js",
31
32
  "types": "./dist/index.d.ts",