@xrmforge/devkit 0.5.7 → 0.6.1

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 CHANGED
@@ -280,7 +280,8 @@ async function generateTemplates(config) {
280
280
  ["eslint.config.js", await loadTemplate("eslint.config.js")],
281
281
  [".github/workflows/ci.yml", await loadTemplate("github-actions-ci.yml")],
282
282
  ["azure-pipelines.yml", await loadTemplate("azure-pipelines.yml")],
283
- ["scripts/self-check.sh", await loadTemplate("self-check.sh")]
283
+ ["scripts/self-check.sh", await loadTemplate("self-check.sh")],
284
+ ["scripts/validate-form.mjs", await loadTemplate("validate-form.mjs")]
284
285
  ];
285
286
  }
286
287
  function generatePackageJson(projectName) {
@@ -295,16 +296,17 @@ function generatePackageJson(projectName) {
295
296
  build: "xrmforge build",
296
297
  watch: "xrmforge build --watch",
297
298
  test: "vitest run",
298
- "test:watch": "vitest"
299
+ "test:watch": "vitest",
300
+ validate: "node scripts/validate-form.mjs"
299
301
  },
300
302
  devDependencies: {
301
303
  "@types/xrm": "^9.0.90",
302
304
  "@typescript-eslint/eslint-plugin": "^8.0.0",
303
305
  "@typescript-eslint/parser": "^8.0.0",
304
- "@xrmforge/cli": "^0.4.8",
306
+ "@xrmforge/cli": "^0.4.10",
305
307
  "@xrmforge/eslint-plugin": "^0.2.1",
306
- "@xrmforge/helpers": "^0.1.3",
307
- "@xrmforge/testing": "^0.2.3",
308
+ "@xrmforge/helpers": "^0.2.0",
309
+ "@xrmforge/testing": "^0.2.4",
308
310
  eslint: "^9.0.0",
309
311
  typescript: "^5.7.0",
310
312
  vitest: "^3.0.0"
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/config.ts","../src/builder/esbuild-builder.ts","../src/scaffold/scaffold.ts","../src/scaffold/template-loader.ts"],"sourcesContent":["/**\r\n * @xrmforge/devkit - Build Error Types\r\n *\r\n * Structured error types for build operations.\r\n */\r\n\r\nexport enum BuildErrorCode {\r\n /** Build configuration is invalid or missing required fields */\r\n CONFIG_INVALID = 'BUILD_6001',\r\n /** Entry point file not found on disk */\r\n ENTRY_NOT_FOUND = 'BUILD_6002',\r\n /** esbuild compilation failed (syntax errors, missing imports) */\r\n BUILD_FAILED = 'BUILD_6003',\r\n /** Error in watch mode */\r\n WATCH_ERROR = 'BUILD_6004',\r\n}\r\n\r\n/**\r\n * Structured error class for build operations.\r\n *\r\n * Carries a machine-readable {@link BuildErrorCode} and optional context\r\n * for debugging. The error message is prefixed with the code (e.g. `[BUILD_6001]`).\r\n *\r\n * @example\r\n * ```typescript\r\n * throw new BuildError(\r\n * BuildErrorCode.ENTRY_NOT_FOUND,\r\n * 'Could not find entry point: ./src/missing.ts',\r\n * { entry: 'my_script' },\r\n * );\r\n * ```\r\n */\r\nexport class BuildError extends Error {\r\n /** Machine-readable error code for programmatic handling. */\r\n public readonly code: BuildErrorCode;\r\n /** Additional context for debugging (e.g. entry name, file path). */\r\n public readonly context: Record<string, unknown>;\r\n\r\n /**\r\n * @param code - Machine-readable error code\r\n * @param message - Human-readable error description\r\n * @param context - Optional key-value pairs for debugging context\r\n */\r\n constructor(code: BuildErrorCode, message: string, context: Record<string, unknown> = {}) {\r\n super(`[${code}] ${message}`);\r\n this.name = 'BuildError';\r\n this.code = code;\r\n this.context = context;\r\n\r\n if (Error.captureStackTrace) {\r\n Error.captureStackTrace(this, BuildError);\r\n }\r\n }\r\n}\r\n","/**\r\n * @xrmforge/devkit - Build Configuration\r\n *\r\n * Types and validation for the `build` section in xrmforge.config.json.\r\n */\r\n\r\nimport { BuildError, BuildErrorCode } from './errors.js';\r\n\r\n/** A single build entry (one WebResource) */\r\nexport interface BuildEntry {\r\n /** Relative path to the TypeScript source file */\r\n input: string;\r\n /** Global namespace for D365 form event binding (e.g. \"Contoso.Account\") */\r\n namespace: string;\r\n /** Optional output filename relative to outDir (defaults to entry key + \".js\") */\r\n out?: string;\r\n}\r\n\r\n/** Build configuration for WebResource bundling */\r\nexport interface BuildConfig {\r\n /** Bundler to use (currently only \"esbuild\") */\r\n bundler?: 'esbuild';\r\n /** Named build entries: key = entry name, value = entry config */\r\n entries: Record<string, BuildEntry>;\r\n /** Output directory for built bundles (default: \"./dist\") */\r\n outDir?: string;\r\n /** JavaScript target version (default: \"es2020\") */\r\n target?: string;\r\n /** Generate source maps (default: true) */\r\n sourcemap?: boolean;\r\n /** Minify output (default: false) */\r\n minify?: boolean;\r\n /** Additional modules to exclude from bundling */\r\n external?: string[];\r\n}\r\n\r\n/** Fully resolved build config with all defaults applied */\r\nexport interface ResolvedBuildConfig {\r\n bundler: 'esbuild';\r\n entries: Record<string, BuildEntry>;\r\n outDir: string;\r\n target: string;\r\n sourcemap: boolean;\r\n minify: boolean;\r\n external: string[];\r\n}\r\n\r\n/**\r\n * Validate a raw build config object parsed from xrmforge.config.json.\r\n *\r\n * Checks that all required fields (entries, input, namespace) are present\r\n * and have valid types. Throws {@link BuildError} with CONFIG_INVALID if\r\n * any validation check fails.\r\n *\r\n * @param raw - Untyped config object to validate\r\n * @returns The validated build configuration\r\n * @throws {BuildError} If any required field is missing or has an invalid type\r\n */\r\nexport function validateBuildConfig(raw: unknown): BuildConfig {\r\n if (!raw || typeof raw !== 'object') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration must be an object.',\r\n );\r\n }\r\n\r\n const config = raw as Record<string, unknown>;\r\n\r\n // entries: required, non-empty object\r\n if (!config['entries'] || typeof config['entries'] !== 'object' || Array.isArray(config['entries'])) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration requires an \"entries\" object with at least one entry.',\r\n );\r\n }\r\n\r\n const entries = config['entries'] as Record<string, unknown>;\r\n const entryNames = Object.keys(entries);\r\n\r\n if (entryNames.length === 0) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n 'Build configuration requires at least one entry in \"entries\".',\r\n );\r\n }\r\n\r\n for (const name of entryNames) {\r\n const entry = entries[name] as Record<string, unknown> | undefined;\r\n\r\n if (!entry || typeof entry !== 'object') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" must be an object with \"input\" and \"namespace\".`,\r\n { entry: name },\r\n );\r\n }\r\n\r\n if (!entry['input'] || typeof entry['input'] !== 'string') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" requires an \"input\" field (path to .ts source file).`,\r\n { entry: name },\r\n );\r\n }\r\n\r\n if (!entry['namespace'] || typeof entry['namespace'] !== 'string') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Entry \"${name}\" requires a \"namespace\" field (e.g. \"Contoso.Account\").`,\r\n { entry: name },\r\n );\r\n }\r\n }\r\n\r\n // bundler: optional, must be \"esbuild\" if set\r\n if (config['bundler'] !== undefined && config['bundler'] !== 'esbuild') {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Unsupported bundler: \"${String(config['bundler'])}\". Currently only \"esbuild\" is supported.`,\r\n { bundler: config['bundler'] },\r\n );\r\n }\r\n\r\n return config as unknown as BuildConfig;\r\n}\r\n\r\n/**\r\n * Apply default values to a validated build config.\r\n *\r\n * Fills in defaults for optional fields: bundler ('esbuild'), outDir ('./dist'),\r\n * target ('es2020'), sourcemap (true), minify (false), external ([]).\r\n *\r\n * @param config - Validated build configuration\r\n * @returns Fully resolved configuration with all defaults applied\r\n */\r\nexport function resolveBuildConfig(config: BuildConfig): ResolvedBuildConfig {\r\n return {\r\n bundler: config.bundler ?? 'esbuild',\r\n entries: config.entries,\r\n outDir: config.outDir ?? './dist',\r\n target: config.target ?? 'es2020',\r\n sourcemap: config.sourcemap ?? true,\r\n minify: config.minify ?? false,\r\n external: config.external ?? [],\r\n };\r\n}\r\n","/**\r\n * @xrmforge/devkit - esbuild Builder\r\n *\r\n * Builds D365 WebResources as IIFE bundles with named globals.\r\n * Abstracts esbuild so users never write esbuild config.\r\n */\r\n\r\nimport * as esbuild from 'esbuild';\r\nimport { stat, mkdir } from 'node:fs/promises';\r\nimport { resolve, dirname } from 'node:path';\r\nimport type { BuildConfig } from '../config.js';\r\nimport { resolveBuildConfig } from '../config.js';\r\nimport { BuildError, BuildErrorCode } from '../errors.js';\r\nimport type { BuildResult, BuildResultEntry } from './types.js';\r\n\r\n/**\r\n * Build all entries defined in the config as IIFE bundles.\r\n *\r\n * @param config - Validated build configuration\r\n * @param cwd - Working directory for resolving relative paths (defaults to process.cwd())\r\n * @returns Build result with per-entry details\r\n */\r\nexport async function build(config: BuildConfig, cwd?: string): Promise<BuildResult> {\r\n const startTime = Date.now();\r\n const resolved = resolveBuildConfig(config);\r\n const basedir = cwd ?? process.cwd();\r\n const outDir = resolve(basedir, resolved.outDir);\r\n\r\n // Ensure output directory exists\r\n await mkdir(outDir, { recursive: true });\r\n\r\n const entryNames = Object.keys(resolved.entries);\r\n const results: BuildResultEntry[] = [];\r\n const errors: string[] = [];\r\n const warnings: string[] = [];\r\n\r\n // Build all entries in parallel\r\n const settled = await Promise.allSettled(\r\n entryNames.map(async (name) => {\r\n const entry = resolved.entries[name]!;\r\n const entryStart = Date.now();\r\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\r\n\r\n // Ensure subdirectory exists for custom out paths\r\n await mkdir(dirname(outFile), { recursive: true });\r\n\r\n const buildOptions: esbuild.BuildOptions = {\r\n entryPoints: [resolve(basedir, entry.input)],\r\n bundle: true,\r\n format: 'iife',\r\n globalName: entry.namespace,\r\n outfile: outFile,\r\n target: [resolved.target],\r\n minify: resolved.minify,\r\n sourcemap: resolved.sourcemap,\r\n treeShaking: true,\r\n logLevel: 'silent',\r\n external: resolved.external,\r\n };\r\n\r\n const result = await esbuild.build(buildOptions);\r\n\r\n // Collect esbuild warnings\r\n for (const w of result.warnings) {\r\n warnings.push(`[${name}] ${w.text}`);\r\n }\r\n\r\n // Get output file size\r\n const stats = await stat(outFile);\r\n\r\n return {\r\n name,\r\n outFile,\r\n sizeBytes: stats.size,\r\n durationMs: Date.now() - entryStart,\r\n } satisfies BuildResultEntry;\r\n }),\r\n );\r\n\r\n for (let i = 0; i < settled.length; i++) {\r\n const outcome = settled[i]!;\r\n const name = entryNames[i]!;\r\n\r\n if (outcome.status === 'fulfilled') {\r\n results.push(outcome.value);\r\n } else {\r\n const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);\r\n // Distinguish \"file not found\" from other build errors\r\n if (errorMsg.includes('Could not resolve') || errorMsg.includes('ENOENT')) {\r\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.ENTRY_NOT_FOUND, errorMsg, { entry: name }).message}`);\r\n } else {\r\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.BUILD_FAILED, errorMsg, { entry: name }).message}`);\r\n }\r\n }\r\n }\r\n\r\n return {\r\n entries: results,\r\n totalDurationMs: Date.now() - startTime,\r\n errors,\r\n warnings,\r\n };\r\n}\r\n\r\n/**\r\n * Start watch mode for all entries.\r\n * Returns a dispose function to stop watching.\r\n *\r\n * @param config - Validated build configuration\r\n * @param options - Watch options\r\n * @returns Object with dispose() to stop watching\r\n */\r\nexport async function watch(\r\n config: BuildConfig,\r\n options?: {\r\n cwd?: string;\r\n onRebuild?: (result: BuildResult) => void;\r\n },\r\n): Promise<{ dispose: () => Promise<void> }> {\r\n const resolved = resolveBuildConfig(config);\r\n const basedir = options?.cwd ?? process.cwd();\r\n const outDir = resolve(basedir, resolved.outDir);\r\n\r\n await mkdir(outDir, { recursive: true });\r\n\r\n const contexts: esbuild.BuildContext[] = [];\r\n\r\n for (const [name, entry] of Object.entries(resolved.entries)) {\r\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\r\n await mkdir(dirname(outFile), { recursive: true });\r\n\r\n const ctx = await esbuild.context({\r\n entryPoints: [resolve(basedir, entry.input)],\r\n bundle: true,\r\n format: 'iife',\r\n globalName: entry.namespace,\r\n outfile: outFile,\r\n target: [resolved.target],\r\n minify: resolved.minify,\r\n sourcemap: resolved.sourcemap,\r\n treeShaking: true,\r\n logLevel: 'silent',\r\n external: resolved.external,\r\n });\r\n\r\n contexts.push(ctx);\r\n await ctx.watch();\r\n }\r\n\r\n return {\r\n dispose: async () => {\r\n for (const ctx of contexts) {\r\n await ctx.dispose();\r\n }\r\n },\r\n };\r\n}\r\n","/**\r\n * @xrmforge/devkit - Project Scaffolding\r\n *\r\n * Generates a complete D365 form scripting project from templates.\r\n */\r\n\r\nimport { mkdir, writeFile, readdir, access } from 'node:fs/promises';\r\nimport { join } from 'node:path';\r\nimport type { ScaffoldConfig, ScaffoldResult } from './types.js';\r\nimport { BuildError, BuildErrorCode } from '../errors.js';\r\nimport { loadTemplate } from './template-loader.js';\r\n\r\n/**\r\n * Scaffold a new D365 form scripting project.\r\n *\r\n * Creates a complete project structure with package.json, tsconfig,\r\n * xrmforge.config.json, example form script, and test file.\r\n *\r\n * @param config - Scaffold configuration\r\n * @returns List of created files and any warnings\r\n * @throws {BuildError} if target directory is not empty (unless files are only dotfiles)\r\n */\r\nexport async function scaffoldProject(config: ScaffoldConfig): Promise<ScaffoldResult> {\r\n const { targetDir } = config;\r\n const filesCreated: string[] = [];\r\n const warnings: string[] = [];\r\n\r\n // Ensure target directory exists\r\n await mkdir(targetDir, { recursive: true });\r\n\r\n // Check if directory is empty (ignore dotfiles and node_modules)\r\n const existing = await readdir(targetDir);\r\n const nonDotFiles = existing.filter((f) => !f.startsWith('.') && f !== 'node_modules');\r\n if (nonDotFiles.length > 0 && !config.force) {\r\n throw new BuildError(\r\n BuildErrorCode.CONFIG_INVALID,\r\n `Target directory is not empty: ${targetDir}\\n` +\r\n `Found: ${nonDotFiles.slice(0, 5).join(', ')}${nonDotFiles.length > 5 ? '...' : ''}\\n` +\r\n `Use --force to scaffold anyway (existing files will be skipped).`,\r\n { targetDir, existingFiles: nonDotFiles },\r\n );\r\n }\r\n\r\n // Create directory structure\r\n const dirs = [\r\n 'src/forms',\r\n 'src/shared',\r\n '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 ['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/self-check.sh', await loadTemplate('self-check.sh')],\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 '@typescript-eslint/eslint-plugin': '^8.0.0',\r\n '@typescript-eslint/parser': '^8.0.0',\r\n '@xrmforge/cli': '^0.4.8',\r\n '@xrmforge/eslint-plugin': '^0.2.1',\r\n '@xrmforge/helpers': '^0.1.3',\r\n '@xrmforge/testing': '^0.2.3',\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 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
+ {"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 '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 ['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/self-check.sh', await loadTemplate('self-check.sh')],\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 '@xrmforge/cli': '^0.4.10',\r\n '@xrmforge/eslint-plugin': '^0.2.1',\r\n '@xrmforge/helpers': '^0.2.0',\r\n '@xrmforge/testing': '^0.2.4',\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 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"]}
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { Logger } from './logger.js';
6
6
  import { NOTIFICATION_IDS } from './constants.js';
7
+ import { FormNotificationLevel } from '@xrmforge/helpers';
7
8
 
8
9
  type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
9
10
 
@@ -73,7 +74,7 @@ function logAndNotify(
73
74
  const message = err instanceof Error ? err.message : String(err);
74
75
  logger.error(`${name} failed`, { err });
75
76
  try {
76
- ctx.getFormContext().ui.setFormNotification(message, 'ERROR', NOTIFICATION_IDS.genericError);
77
+ ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
77
78
  } catch {
78
79
  /* ignore */
79
80
  }
@@ -88,7 +89,7 @@ function logAndNotifyForm(
88
89
  const message = err instanceof Error ? err.message : String(err);
89
90
  logger.error(`${name} failed`, { err });
90
91
  try {
91
- formContext.ui.setFormNotification(message, 'ERROR', NOTIFICATION_IDS.genericError);
92
+ formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
92
93
  } catch {
93
94
  /* ignore */
94
95
  }
@@ -1,19 +1,34 @@
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, vi } from 'vitest';
2
+
3
+ /**
4
+ * Example test for the form script.
5
+ *
6
+ * Uses @xrmforge/testing for type-safe mocking once you have
7
+ * generated types. Replace with real form tests after 'xrmforge generate'.
8
+ */
9
+ describe('{{namespace}}.Example', () => {
10
+ it('should export onLoad handler', async () => {
11
+ const mod = await import('../../src/forms/example-form.js');
12
+ expect(typeof mod.onLoad).toBe('function');
13
+ });
14
+
15
+ it('should export onSave handler', async () => {
16
+ const mod = await import('../../src/forms/example-form.js');
17
+ expect(typeof mod.onSave).toBe('function');
18
+ });
19
+
20
+ // TODO: After running 'xrmforge generate', add real form tests:
21
+ //
22
+ // import { createFormMock } from '@xrmforge/testing';
23
+ // import type { ExampleFormMockValues } from '../../generated/entities/Example.js';
24
+ //
25
+ // it('should show notification on load', () => {
26
+ // const { executionContext, formContext } = createFormMock<ExampleFormMockValues>({
27
+ // name: 'Contoso Ltd',
28
+ // });
29
+ // onLoad(executionContext);
30
+ // expect(formContext.ui.setFormNotification).toHaveBeenCalledWith(
31
+ // 'Form loaded successfully', 'INFO', 'example-load-notification'
32
+ // );
33
+ // });
34
+ });
@@ -1,40 +1,61 @@
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 using XrmForge best practices.
3
+ *
4
+ * Register in D365 as: {{namespace}}.Example.onLoad
5
+ *
6
+ * This template demonstrates the correct patterns:
7
+ * - Fields Enum for compile-time field validation
8
+ * - Typed FormContext cast
9
+ * - wrapHandler for unified error handling
10
+ * - Logger instead of console.log
11
+ * - FormNotificationLevel constant instead of raw string
12
+ * - select() for Web API queries
13
+ *
14
+ * Replace this file with your actual form logic after running 'xrmforge generate'.
15
+ */
16
+ import { createLogger } from '../shared/logger.js';
17
+ import { wrapHandler } from '../shared/error-handler.js';
18
+ import { FormNotificationLevel, select } from '@xrmforge/helpers';
19
+ // TODO: Import your generated types after running 'xrmforge generate'
20
+ // import type { ExampleForm } from '../../generated/entities/Example.js';
21
+ // import { ExampleFormFieldsEnum as Fields } from '../../generated/entities/Example.js';
22
+
23
+ const logger = createLogger('{{namespace}}.Example');
24
+
25
+ /**
26
+ * Called when the form loads.
27
+ */
28
+ export const onLoad = wrapHandler('{{namespace}}.Example.onLoad', logger, (executionContext) => {
29
+ const form = executionContext.getFormContext();
30
+ // TODO: Cast to your generated form type:
31
+ // const form = executionContext.getFormContext() as ExampleForm;
32
+
33
+ // Example: show a notification on the form
34
+ form.ui.setFormNotification(
35
+ 'Form loaded successfully',
36
+ FormNotificationLevel.Info,
37
+ 'example-load-notification',
38
+ );
39
+
40
+ // Example: read a field value using Fields Enum
41
+ // const nameAttr = form.getAttribute(Fields.Name);
42
+ // logger.debug('Name field value', { value: nameAttr.getValue() });
43
+
44
+ // Example: Web API query with select()
45
+ // const ref = formLookup(form.getAttribute(Fields.ParentAccountId));
46
+ // if (ref) {
47
+ // const result = await Xrm.WebApi.retrieveRecord(
48
+ // EntityNames.Account, ref.id, select(AccountFields.Name, AccountFields.WebsiteUrl)
49
+ // );
50
+ // }
51
+ });
52
+
53
+ /**
54
+ * Called when the form is saved.
55
+ */
56
+ export const onSave = wrapHandler('{{namespace}}.Example.onSave', logger, (executionContext) => {
57
+ const form = executionContext.getFormContext();
58
+
59
+ // Clear the load notification on save
60
+ form.ui.clearFormNotification('example-load-notification');
61
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * validate-form.mjs - Quality Gate for XrmForge Form Scripts
3
+ *
4
+ * Runs three checks in one command:
5
+ * 1. TypeScript Compiler (tsc --noEmit)
6
+ * 2. ESLint (src/ --max-warnings=0)
7
+ * 3. Pattern Compliance (critical rule violations via text search)
8
+ *
9
+ * Exit code: 0 = all green, 1 = errors found
10
+ *
11
+ * Usage:
12
+ * node scripts/validate-form.mjs
13
+ * npm run validate
14
+ */
15
+
16
+ import { execSync } from 'node:child_process';
17
+ import { readdirSync, readFileSync } from 'node:fs';
18
+ import { join, relative } from 'node:path';
19
+
20
+ const RED = '\x1b[31m';
21
+ const GREEN = '\x1b[32m';
22
+ const NC = '\x1b[0m';
23
+
24
+ let totalErrors = 0;
25
+
26
+ // ============================================================
27
+ // 1. TypeScript Compiler
28
+ // ============================================================
29
+
30
+ console.log('=== XrmForge Quality Gate ===\n');
31
+ console.log('--- TypeScript ---');
32
+
33
+ try {
34
+ execSync('npx tsc --noEmit', { stdio: 'pipe', encoding: 'utf-8' });
35
+ console.log(`${GREEN}OK${NC} tsc --noEmit`);
36
+ } catch (err) {
37
+ const output = (err.stdout || '') + (err.stderr || '');
38
+ const errorCount = (output.match(/error TS/g) || []).length;
39
+ console.log(`${RED}FAIL${NC} tsc --noEmit (${errorCount} errors)`);
40
+ console.log(output.split('\n').slice(0, 20).join('\n'));
41
+ totalErrors += errorCount || 1;
42
+ }
43
+
44
+ // ============================================================
45
+ // 2. ESLint
46
+ // ============================================================
47
+
48
+ console.log('\n--- ESLint ---');
49
+
50
+ try {
51
+ execSync('npx eslint src/ --max-warnings=0', { stdio: 'pipe', encoding: 'utf-8' });
52
+ console.log(`${GREEN}OK${NC} eslint src/ --max-warnings=0`);
53
+ } catch (err) {
54
+ const output = (err.stdout || '') + (err.stderr || '');
55
+ const problemMatch = output.match(/(\d+) problems?/);
56
+ const count = problemMatch ? problemMatch[1] : '?';
57
+ console.log(`${RED}FAIL${NC} eslint (${count} problems)`);
58
+ console.log(output.split('\n').slice(0, 20).join('\n'));
59
+ totalErrors += parseInt(count) || 1;
60
+ }
61
+
62
+ // ============================================================
63
+ // 3. Pattern Compliance (critical rules)
64
+ // ============================================================
65
+
66
+ console.log('\n--- Pattern Compliance ---');
67
+
68
+ /**
69
+ * Collect all .ts files recursively from a directory.
70
+ */
71
+ function collectTsFiles(dir) {
72
+ const results = [];
73
+ try {
74
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
75
+ const fullPath = join(dir, entry.name);
76
+ if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
77
+ results.push(...collectTsFiles(fullPath));
78
+ } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
79
+ results.push(fullPath);
80
+ }
81
+ }
82
+ } catch {
83
+ // Directory does not exist
84
+ }
85
+ return results;
86
+ }
87
+
88
+ /**
89
+ * Check a pattern rule against all files.
90
+ * @returns Number of violations
91
+ */
92
+ function checkPattern(label, files, regex, excludeFiles = []) {
93
+ const violations = [];
94
+ for (const file of files) {
95
+ const relPath = relative(process.cwd(), file);
96
+ if (excludeFiles.some((ex) => relPath.includes(ex))) continue;
97
+
98
+ const lines = readFileSync(file, 'utf-8').split('\n');
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const line = lines[i];
101
+ const trimmed = line.trim();
102
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
103
+ if (regex.test(line)) {
104
+ violations.push(` ${relPath}:${i + 1}: ${trimmed}`);
105
+ }
106
+ }
107
+ }
108
+
109
+ if (violations.length === 0) {
110
+ console.log(`${GREEN}OK${NC} [0] ${label}`);
111
+ } else {
112
+ console.log(`${RED}FAIL${NC} [${violations.length}] ${label}`);
113
+ violations.slice(0, 10).forEach((v) => console.log(v));
114
+ if (violations.length > 10) {
115
+ console.log(` ... and ${violations.length - 10} more`);
116
+ }
117
+ totalErrors += violations.length;
118
+ }
119
+ return violations.length;
120
+ }
121
+
122
+ const formFiles = collectTsFiles('src/forms');
123
+ const allSrcFiles = collectTsFiles('src');
124
+
125
+ // 3a. Raw strings in getAttribute/getControl (must use Fields Enum)
126
+ checkPattern(
127
+ 'Raw field strings in getAttribute/getControl (must use Fields Enum)',
128
+ formFiles,
129
+ /(?:getAttribute|getControl)\s*\(\s*['"][a-z]/,
130
+ );
131
+
132
+ // 3b. console.log/warn/error outside logger.ts (must use Logger)
133
+ checkPattern(
134
+ 'console.* outside logger.ts (must use Logger)',
135
+ allSrcFiles,
136
+ /\bconsole\.(log|warn|error|info|debug)\b/,
137
+ ['logger.ts'],
138
+ );
139
+
140
+ // 3c. Exported handlers without wrapHandler
141
+ checkPattern(
142
+ 'Exported handlers without wrapHandler',
143
+ formFiles,
144
+ /^export\s+(const|async\s+function|function)\s+\w+(?!.*wrapHandler)/,
145
+ );
146
+
147
+ // 3d. Raw entity name strings in WebApi calls (must use EntityNames Enum)
148
+ checkPattern(
149
+ 'Raw entity names in WebApi (must use EntityNames Enum)',
150
+ allSrcFiles,
151
+ /(?:retrieveRecord|retrieveMultipleRecords|createRecord|updateRecord|deleteRecord)\s*\(\s*['"]/,
152
+ ['generated/'],
153
+ );
154
+
155
+ // 3e. Xrm.Page (deprecated since D365 v9.0)
156
+ checkPattern(
157
+ 'Xrm.Page (deprecated since D365 v9.0)',
158
+ allSrcFiles,
159
+ /\bXrm\.Page\b/,
160
+ );
161
+
162
+ // 3f. Raw $select strings (must use select() from @xrmforge/helpers)
163
+ checkPattern(
164
+ 'Raw $select strings (must use select() from @xrmforge/helpers)',
165
+ allSrcFiles,
166
+ /['"]\?\$select=/,
167
+ ['generated/'],
168
+ );
169
+
170
+ // ============================================================
171
+ // Result
172
+ // ============================================================
173
+
174
+ console.log('\n=== Result ===');
175
+ if (totalErrors === 0) {
176
+ console.log(`${GREEN}All checks passed. 0 violations.${NC}`);
177
+ process.exit(0);
178
+ } else {
179
+ console.log(`${RED}${totalErrors} violations found.${NC}`);
180
+ process.exit(1);
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrmforge/devkit",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
4
4
  "description": "Build orchestration and project tooling for Dynamics 365 WebResources",
5
5
  "keywords": [
6
6
  "dynamics-365",