@xrmforge/devkit 0.7.31 → 0.7.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/templates/AGENT.md +31 -6
- package/dist/templates/error-handler.ts +158 -158
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -310,7 +310,8 @@ 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.
|
|
313
|
+
// helpers ^0.13.0: expanded<T>()/expandedMany<T>() typed $expand readers ship in 0.13.0
|
|
314
|
+
// (F-MK9-08); addAppNotification autoHideMs (self-clearing transient banners) since 0.12.0
|
|
314
315
|
// (Runde 9 F-MK9-10); on-form setAndSubmit, off-form formLookupIdUnsafe/formLookupUnsafe,
|
|
315
316
|
// getEnvironmentVariable, isUnsavedRecord since 0.11.0 (Runde 8: F-LMA8-N1/N2, F-MK8-N4a/b);
|
|
316
317
|
// MultiSelect/submit/app-notification (parseMultiSelect, clearAndSubmit, setUnsafeAndSubmit,
|
|
@@ -321,7 +322,7 @@ function generatePackageJson(projectName) {
|
|
|
321
322
|
// utilityOverrides) since 0.4.0; tabs since 0.3.0.
|
|
322
323
|
"@xrmforge/cli": "^0.8.0",
|
|
323
324
|
"@xrmforge/eslint-plugin": "^0.3.0",
|
|
324
|
-
"@xrmforge/helpers": "^0.
|
|
325
|
+
"@xrmforge/helpers": "^0.13.0",
|
|
325
326
|
"@xrmforge/testing": "^0.6.0",
|
|
326
327
|
eslint: "^9.0.0",
|
|
327
328
|
typescript: "^5.7.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.12.0: addAppNotification autoHideMs (self-clearing transient banners) ships in 0.12.0\r\n // (Runde 9 F-MK9-10); on-form setAndSubmit, off-form formLookupIdUnsafe/formLookupUnsafe,\r\n // getEnvironmentVariable, isUnsavedRecord since 0.11.0 (Runde 8: F-LMA8-N1/N2, F-MK8-N4a/b);\r\n // MultiSelect/submit/app-notification (parseMultiSelect, clearAndSubmit, setUnsafeAndSubmit,\r\n // addAppNotification) since 0.10.0; void Custom API executors since 0.9.0; isFormType since 0.8.0.\r\n // testing ^0.6.0: subgrid MockControl.refresh() ships in 0.6.0 (Runde 9: F-MK9-01); online.execute\r\n // override + OptionSet/view/setFilterXml mock methods since 0.5.0 (Runde 8: F-MK8-04b); complex-form\r\n // mocks (createFormMock formType, getText/getPrecision, addOnSave/fireOnSave, roles ItemCollection,\r\n // utilityOverrides) since 0.4.0; tabs since 0.3.0.\r\n '@xrmforge/cli': '^0.8.0',\r\n '@xrmforge/eslint-plugin': '^0.3.0',\r\n '@xrmforge/helpers': '^0.12.0',\r\n '@xrmforge/testing': '^0.6.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"]}
|
|
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.13.0: expanded<T>()/expandedMany<T>() typed $expand readers ship in 0.13.0\r\n // (F-MK9-08); addAppNotification autoHideMs (self-clearing transient banners) since 0.12.0\r\n // (Runde 9 F-MK9-10); on-form setAndSubmit, off-form formLookupIdUnsafe/formLookupUnsafe,\r\n // getEnvironmentVariable, isUnsavedRecord since 0.11.0 (Runde 8: F-LMA8-N1/N2, F-MK8-N4a/b);\r\n // MultiSelect/submit/app-notification (parseMultiSelect, clearAndSubmit, setUnsafeAndSubmit,\r\n // addAppNotification) since 0.10.0; void Custom API executors since 0.9.0; isFormType since 0.8.0.\r\n // testing ^0.6.0: subgrid MockControl.refresh() ships in 0.6.0 (Runde 9: F-MK9-01); online.execute\r\n // override + OptionSet/view/setFilterXml mock methods since 0.5.0 (Runde 8: F-MK8-04b); complex-form\r\n // mocks (createFormMock formType, getText/getPrecision, addOnSave/fireOnSave, roles ItemCollection,\r\n // utilityOverrides) since 0.4.0; tabs since 0.3.0.\r\n '@xrmforge/cli': '^0.8.0',\r\n '@xrmforge/eslint-plugin': '^0.3.0',\r\n '@xrmforge/helpers': '^0.13.0',\r\n '@xrmforge/testing': '^0.6.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
|
@@ -162,13 +162,13 @@ form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
|
|
|
162
162
|
// Web API queries (entity-level Fields):
|
|
163
163
|
import { AccountFields } from '../../generated/fields/account.js';
|
|
164
164
|
Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
|
|
165
|
-
select(AccountFields.Name, AccountFields.
|
|
165
|
+
select(AccountFields.Name, AccountFields.WebSiteURL));
|
|
166
166
|
```
|
|
167
167
|
|
|
168
168
|
**NEVER use raw strings in select():**
|
|
169
169
|
```typescript
|
|
170
170
|
select('name', 'websiteurl') // BUG - raw strings
|
|
171
|
-
select(AccountFields.Name, AccountFields.
|
|
171
|
+
select(AccountFields.Name, AccountFields.WebSiteURL) // CORRECT
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
### 3. OptionSet Enum for ALL value comparisons AND FetchXML
|
|
@@ -183,7 +183,7 @@ if (status === InvoiceStatusCode.Gebucht) { ... } // CORRECT
|
|
|
183
183
|
if (status === 105710002) { ... } // BUG
|
|
184
184
|
|
|
185
185
|
// FetchXML:
|
|
186
|
-
`<condition attribute='${InvoiceFields.
|
|
186
|
+
`<condition attribute='${InvoiceFields.StatusCode}' operator='in'>
|
|
187
187
|
<value>${InvoiceStatusCode.Aktiv}</value>
|
|
188
188
|
<value>${InvoiceStatusCode.Gebucht}</value>
|
|
189
189
|
</condition>` // CORRECT
|
|
@@ -224,6 +224,13 @@ compiles green but breaks at runtime (no tsc/eslint gate catches it):
|
|
|
224
224
|
| `XxxFields` | `'_transactioncurrencyid_value'` (already `_value`-form) | `$select`, `$filter` |
|
|
225
225
|
| `XxxNavigationProperties` | `'transactioncurrencyid'` (blank) | `parseLookup`, `$expand`, `@odata.bind`, `$unsafe` (lookup) |
|
|
226
226
|
|
|
227
|
+
**Member naming:** `XxxFields`, `XxxNavigationProperties` and the form-level `XxxFormFieldsEnum` name their
|
|
228
|
+
members after the attribute **SchemaName** (the cased logical name, e.g. `statecode` -> `StateCode`,
|
|
229
|
+
`markant_kin` -> `markant_kin`), NOT the display label. The member is therefore guessable from the logical
|
|
230
|
+
name and stable across regenerations (renaming a field's label never shifts it, and two fields with the same
|
|
231
|
+
label never collide). The human-readable label lives in the member's JSDoc (IDE tooltip). When unsure of a
|
|
232
|
+
member name, derive it from the logical name (not the UI label).
|
|
233
|
+
|
|
227
234
|
```typescript
|
|
228
235
|
import { AccountFields } from '../../generated/fields/account.js';
|
|
229
236
|
import { AccountNavigationProperties as AccountNav } from '../../generated/fields/account.js';
|
|
@@ -271,18 +278,36 @@ import { AccountFields } from '../../generated/fields/account.js';
|
|
|
271
278
|
select(AccountFields.Name, AccountFields.Revenue)
|
|
272
279
|
|
|
273
280
|
// $filter (field names via template literal):
|
|
274
|
-
`${select(AccountFields.Name)}&$filter=${AccountFields.
|
|
281
|
+
`${select(AccountFields.Name)}&$filter=${AccountFields.StateCode} eq 0`
|
|
275
282
|
|
|
276
283
|
// $expand (navigation properties):
|
|
277
284
|
selectExpand(
|
|
278
285
|
[AccountFields.Name, AccountFields.Revenue],
|
|
279
|
-
`primarycontactid($select=${ContactFields.
|
|
286
|
+
`primarycontactid($select=${ContactFields.FullName})`,
|
|
280
287
|
)
|
|
281
288
|
|
|
282
289
|
// $orderby:
|
|
283
290
|
`${select(AccountFields.Name)}&$orderby=${AccountFields.Name} asc`
|
|
284
291
|
```
|
|
285
292
|
|
|
293
|
+
**Reading `$expand` results: use `expanded<T>()` / `expandedMany<T>()`, never a hand-cast.** An expanded
|
|
294
|
+
navigation property comes back as a nested object (single-valued lookup) or array (1:N/N:N) under the nav
|
|
295
|
+
property name. Type it with the generated Entity interface; the result is `Partial<T>` because a partial
|
|
296
|
+
`$select` inside the `$expand` only returns the selected fields.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
import { expanded, expandedMany } from '@xrmforge/helpers';
|
|
300
|
+
|
|
301
|
+
// single-valued ($expand on a lookup):
|
|
302
|
+
const contact = expanded<Contact>(account, AccountNav.PrimaryContactId); // Partial<Contact> | null
|
|
303
|
+
contact?.fullname;
|
|
304
|
+
// collection-valued ($expand on a 1:N):
|
|
305
|
+
for (const c of expandedMany<Contact>(account, 'contact_customer_accounts')) { c.fullname; }
|
|
306
|
+
|
|
307
|
+
// WRONG: hand-cast loses honesty about partial $select and is unchecked
|
|
308
|
+
const c = account['primarycontactid'] as { fullname?: string };
|
|
309
|
+
```
|
|
310
|
+
|
|
286
311
|
### 6b. Web API response typing with generated Entity interfaces
|
|
287
312
|
|
|
288
313
|
Always type Web API responses with generated Entity interfaces. Never access properties with `as string` casts.
|
|
@@ -576,7 +601,7 @@ if (form.statuscode.getValue() === StatusCode.Gebucht) { ... }
|
|
|
576
601
|
// AFTER:
|
|
577
602
|
import { LmBestellungFields } from '../../generated/fields/lm_bestellung.js';
|
|
578
603
|
import { LmBestellungStatusCode } from '../../generated/optionsets/lm_bestellung.js';
|
|
579
|
-
`<condition attribute='${LmBestellungFields.
|
|
604
|
+
`<condition attribute='${LmBestellungFields.StatusCode}' operator='in'>
|
|
580
605
|
<value>${LmBestellungStatusCode.Aktiv}</value>
|
|
581
606
|
<value>${LmBestellungStatusCode.InArbeit}</value>
|
|
582
607
|
</condition>`
|
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified error handling for D365 form event handlers and ribbon commands.
|
|
3
|
-
* Wraps sync and async handlers with try/catch: form handlers and form commands
|
|
4
|
-
* show a form notification, subgrid commands an app-level notification banner.
|
|
5
|
-
*/
|
|
6
|
-
import type { Logger } from './logger.js';
|
|
7
|
-
import { NOTIFICATION_IDS } from './constants.js';
|
|
8
|
-
import { FormNotificationLevel, AppNotificationLevel, addAppNotification } from '@xrmforge/helpers';
|
|
9
|
-
|
|
10
|
-
type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Wrap a form event handler with error handling.
|
|
14
|
-
*
|
|
15
|
-
* Catches both sync and async errors, logs them, and shows a form notification.
|
|
16
|
-
* The original handler is never rethrown, so form execution continues.
|
|
17
|
-
*
|
|
18
|
-
* @param name - Handler name for logging (e.g. 'MyApp.Account.onLoad')
|
|
19
|
-
* @param logger - Logger instance for error reporting
|
|
20
|
-
* @param handler - The actual event handler function
|
|
21
|
-
*/
|
|
22
|
-
export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler {
|
|
23
|
-
const wrapped: EventHandler = (ctx, ...args) => {
|
|
24
|
-
try {
|
|
25
|
-
const result = handler(ctx, ...args);
|
|
26
|
-
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
27
|
-
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
28
|
-
logAndNotify(ctx, name, logger, err);
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
return result;
|
|
32
|
-
} catch (err: unknown) {
|
|
33
|
-
logAndNotify(ctx, name, logger, err);
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
return wrapped;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Wrap a ribbon command handler (form context) with error handling.
|
|
41
|
-
*
|
|
42
|
-
* Unlike wrapHandler, this accepts a FormContext directly (not an EventContext),
|
|
43
|
-
* which is the calling convention for ribbon/command bar handlers.
|
|
44
|
-
*
|
|
45
|
-
* Pass extra ribbon command parameters via the TArgs type parameter so they stay
|
|
46
|
-
* typed end to end (e.g. `wrapCommand<[boolean]>(...)` for a handler that takes a
|
|
47
|
-
* flag after the form context). TArgs defaults to `[]` (no extra parameters).
|
|
48
|
-
*
|
|
49
|
-
* For commands registered on a subgrid (the PrimaryControl may be a GridControl,
|
|
50
|
-
* not a FormContext) use {@link wrapGridCommand} instead.
|
|
51
|
-
*
|
|
52
|
-
* @typeParam TArgs - Tuple of extra parameters passed after the form context
|
|
53
|
-
* @param name - Handler name for logging
|
|
54
|
-
* @param logger - Logger instance for error reporting
|
|
55
|
-
* @param handler - The actual command handler function
|
|
56
|
-
*/
|
|
57
|
-
export function wrapCommand<TArgs extends unknown[] = []>(
|
|
58
|
-
name: string,
|
|
59
|
-
logger: Logger,
|
|
60
|
-
handler: (formContext: Xrm.FormContext, ...args: TArgs) => unknown,
|
|
61
|
-
): (formContext: Xrm.FormContext, ...args: TArgs) => unknown {
|
|
62
|
-
return (formContext, ...args) => {
|
|
63
|
-
try {
|
|
64
|
-
const result = handler(formContext, ...args);
|
|
65
|
-
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
66
|
-
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
67
|
-
logAndNotifyForm(formContext, name, logger, err);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
return result;
|
|
71
|
-
} catch (err: unknown) {
|
|
72
|
-
logAndNotifyForm(formContext, name, logger, err);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Wrap a ribbon command handler whose PrimaryControl can be a subgrid.
|
|
79
|
-
*
|
|
80
|
-
* Commands registered on a subgrid (or on both a form and a subgrid) receive a
|
|
81
|
-
* `GridControl` as the first argument when fired from the grid, plus the selected
|
|
82
|
-
* record ids. A `GridControl` has no form `ui`, so a form notification cannot be
|
|
83
|
-
* shown - this variant reports errors via the logger and an app-level banner
|
|
84
|
-
* ({@link addAppNotification}) that works independently of the form context.
|
|
85
|
-
*
|
|
86
|
-
* TArgs defaults to `[string[]]` (the selected record ids that D365 passes as the
|
|
87
|
-
* SelectedControlSelectedItemIds command parameter).
|
|
88
|
-
*
|
|
89
|
-
* @typeParam TArgs - Tuple of extra parameters passed after the primary control
|
|
90
|
-
* @param name - Handler name for logging
|
|
91
|
-
* @param logger - Logger instance for error reporting
|
|
92
|
-
* @param handler - The actual command handler function
|
|
93
|
-
*/
|
|
94
|
-
export function wrapGridCommand<TArgs extends unknown[] = [string[]]>(
|
|
95
|
-
name: string,
|
|
96
|
-
logger: Logger,
|
|
97
|
-
handler: (primaryControl: Xrm.FormContext | Xrm.Controls.GridControl, ...args: TArgs) => unknown,
|
|
98
|
-
): (primaryControl: Xrm.FormContext | Xrm.Controls.GridControl, ...args: TArgs) => unknown {
|
|
99
|
-
return (primaryControl, ...args) => {
|
|
100
|
-
try {
|
|
101
|
-
const result = handler(primaryControl, ...args);
|
|
102
|
-
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
103
|
-
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
104
|
-
logAndNotifyApp(name, logger, err);
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return result;
|
|
108
|
-
} catch (err: unknown) {
|
|
109
|
-
logAndNotifyApp(name, logger, err);
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function logAndNotify(
|
|
115
|
-
ctx: Xrm.Events.EventContext,
|
|
116
|
-
name: string,
|
|
117
|
-
logger: Logger,
|
|
118
|
-
err: unknown,
|
|
119
|
-
): void {
|
|
120
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
-
logger.error(`${name} failed`, { err });
|
|
122
|
-
try {
|
|
123
|
-
ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
124
|
-
} catch {
|
|
125
|
-
/* ignore */
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function logAndNotifyForm(
|
|
130
|
-
formContext: Xrm.FormContext,
|
|
131
|
-
name: string,
|
|
132
|
-
logger: Logger,
|
|
133
|
-
err: unknown,
|
|
134
|
-
): void {
|
|
135
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
-
logger.error(`${name} failed`, { err });
|
|
137
|
-
try {
|
|
138
|
-
formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
139
|
-
} catch {
|
|
140
|
-
/* ignore */
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Log an error and surface it as an app-level (global) notification banner.
|
|
146
|
-
*
|
|
147
|
-
* Used by {@link wrapGridCommand}: the PrimaryControl may be a GridControl that
|
|
148
|
-
* has no form `ui`, so a form notification is not available. The app banner is
|
|
149
|
-
* shown regardless of the calling control. The async banner call is
|
|
150
|
-
* fire-and-forget so the command handler is not forced to be async.
|
|
151
|
-
*/
|
|
152
|
-
function logAndNotifyApp(name: string, logger: Logger, err: unknown): void {
|
|
153
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
154
|
-
logger.error(`${name} failed`, { err });
|
|
155
|
-
void addAppNotification(message, AppNotificationLevel.Error).catch(() => {
|
|
156
|
-
/* ignore */
|
|
157
|
-
});
|
|
158
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Unified error handling for D365 form event handlers and ribbon commands.
|
|
3
|
+
* Wraps sync and async handlers with try/catch: form handlers and form commands
|
|
4
|
+
* show a form notification, subgrid commands an app-level notification banner.
|
|
5
|
+
*/
|
|
6
|
+
import type { Logger } from './logger.js';
|
|
7
|
+
import { NOTIFICATION_IDS } from './constants.js';
|
|
8
|
+
import { FormNotificationLevel, AppNotificationLevel, addAppNotification } from '@xrmforge/helpers';
|
|
9
|
+
|
|
10
|
+
type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wrap a form event handler with error handling.
|
|
14
|
+
*
|
|
15
|
+
* Catches both sync and async errors, logs them, and shows a form notification.
|
|
16
|
+
* The original handler is never rethrown, so form execution continues.
|
|
17
|
+
*
|
|
18
|
+
* @param name - Handler name for logging (e.g. 'MyApp.Account.onLoad')
|
|
19
|
+
* @param logger - Logger instance for error reporting
|
|
20
|
+
* @param handler - The actual event handler function
|
|
21
|
+
*/
|
|
22
|
+
export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler {
|
|
23
|
+
const wrapped: EventHandler = (ctx, ...args) => {
|
|
24
|
+
try {
|
|
25
|
+
const result = handler(ctx, ...args);
|
|
26
|
+
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
27
|
+
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
28
|
+
logAndNotify(ctx, name, logger, err);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
} catch (err: unknown) {
|
|
33
|
+
logAndNotify(ctx, name, logger, err);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wrap a ribbon command handler (form context) with error handling.
|
|
41
|
+
*
|
|
42
|
+
* Unlike wrapHandler, this accepts a FormContext directly (not an EventContext),
|
|
43
|
+
* which is the calling convention for ribbon/command bar handlers.
|
|
44
|
+
*
|
|
45
|
+
* Pass extra ribbon command parameters via the TArgs type parameter so they stay
|
|
46
|
+
* typed end to end (e.g. `wrapCommand<[boolean]>(...)` for a handler that takes a
|
|
47
|
+
* flag after the form context). TArgs defaults to `[]` (no extra parameters).
|
|
48
|
+
*
|
|
49
|
+
* For commands registered on a subgrid (the PrimaryControl may be a GridControl,
|
|
50
|
+
* not a FormContext) use {@link wrapGridCommand} instead.
|
|
51
|
+
*
|
|
52
|
+
* @typeParam TArgs - Tuple of extra parameters passed after the form context
|
|
53
|
+
* @param name - Handler name for logging
|
|
54
|
+
* @param logger - Logger instance for error reporting
|
|
55
|
+
* @param handler - The actual command handler function
|
|
56
|
+
*/
|
|
57
|
+
export function wrapCommand<TArgs extends unknown[] = []>(
|
|
58
|
+
name: string,
|
|
59
|
+
logger: Logger,
|
|
60
|
+
handler: (formContext: Xrm.FormContext, ...args: TArgs) => unknown,
|
|
61
|
+
): (formContext: Xrm.FormContext, ...args: TArgs) => unknown {
|
|
62
|
+
return (formContext, ...args) => {
|
|
63
|
+
try {
|
|
64
|
+
const result = handler(formContext, ...args);
|
|
65
|
+
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
66
|
+
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
67
|
+
logAndNotifyForm(formContext, name, logger, err);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
} catch (err: unknown) {
|
|
72
|
+
logAndNotifyForm(formContext, name, logger, err);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wrap a ribbon command handler whose PrimaryControl can be a subgrid.
|
|
79
|
+
*
|
|
80
|
+
* Commands registered on a subgrid (or on both a form and a subgrid) receive a
|
|
81
|
+
* `GridControl` as the first argument when fired from the grid, plus the selected
|
|
82
|
+
* record ids. A `GridControl` has no form `ui`, so a form notification cannot be
|
|
83
|
+
* shown - this variant reports errors via the logger and an app-level banner
|
|
84
|
+
* ({@link addAppNotification}) that works independently of the form context.
|
|
85
|
+
*
|
|
86
|
+
* TArgs defaults to `[string[]]` (the selected record ids that D365 passes as the
|
|
87
|
+
* SelectedControlSelectedItemIds command parameter).
|
|
88
|
+
*
|
|
89
|
+
* @typeParam TArgs - Tuple of extra parameters passed after the primary control
|
|
90
|
+
* @param name - Handler name for logging
|
|
91
|
+
* @param logger - Logger instance for error reporting
|
|
92
|
+
* @param handler - The actual command handler function
|
|
93
|
+
*/
|
|
94
|
+
export function wrapGridCommand<TArgs extends unknown[] = [string[]]>(
|
|
95
|
+
name: string,
|
|
96
|
+
logger: Logger,
|
|
97
|
+
handler: (primaryControl: Xrm.FormContext | Xrm.Controls.GridControl, ...args: TArgs) => unknown,
|
|
98
|
+
): (primaryControl: Xrm.FormContext | Xrm.Controls.GridControl, ...args: TArgs) => unknown {
|
|
99
|
+
return (primaryControl, ...args) => {
|
|
100
|
+
try {
|
|
101
|
+
const result = handler(primaryControl, ...args);
|
|
102
|
+
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
103
|
+
return (result as Promise<unknown>).catch((err: unknown) => {
|
|
104
|
+
logAndNotifyApp(name, logger, err);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
} catch (err: unknown) {
|
|
109
|
+
logAndNotifyApp(name, logger, err);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function logAndNotify(
|
|
115
|
+
ctx: Xrm.Events.EventContext,
|
|
116
|
+
name: string,
|
|
117
|
+
logger: Logger,
|
|
118
|
+
err: unknown,
|
|
119
|
+
): void {
|
|
120
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
+
logger.error(`${name} failed`, { err });
|
|
122
|
+
try {
|
|
123
|
+
ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function logAndNotifyForm(
|
|
130
|
+
formContext: Xrm.FormContext,
|
|
131
|
+
name: string,
|
|
132
|
+
logger: Logger,
|
|
133
|
+
err: unknown,
|
|
134
|
+
): void {
|
|
135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
+
logger.error(`${name} failed`, { err });
|
|
137
|
+
try {
|
|
138
|
+
formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
|
|
139
|
+
} catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Log an error and surface it as an app-level (global) notification banner.
|
|
146
|
+
*
|
|
147
|
+
* Used by {@link wrapGridCommand}: the PrimaryControl may be a GridControl that
|
|
148
|
+
* has no form `ui`, so a form notification is not available. The app banner is
|
|
149
|
+
* shown regardless of the calling control. The async banner call is
|
|
150
|
+
* fire-and-forget so the command handler is not forced to be async.
|
|
151
|
+
*/
|
|
152
|
+
function logAndNotifyApp(name: string, logger: Logger, err: unknown): void {
|
|
153
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
154
|
+
logger.error(`${name} failed`, { err });
|
|
155
|
+
void addAppNotification(message, AppNotificationLevel.Error).catch(() => {
|
|
156
|
+
/* ignore */
|
|
157
|
+
});
|
|
158
|
+
}
|