@xrmforge/devkit 0.4.0 → 0.5.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
@@ -191,7 +191,26 @@ async function watch(config, options) {
191
191
 
192
192
  // src/scaffold/scaffold.ts
193
193
  import { mkdir as mkdir2, writeFile, readdir, access } from "fs/promises";
194
- import { join } from "path";
194
+ import { join as join2 } from "path";
195
+
196
+ // src/scaffold/template-loader.ts
197
+ import { readFile } from "fs/promises";
198
+ import { dirname as dirname2, join } from "path";
199
+ import { fileURLToPath } from "url";
200
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
201
+ var TEMPLATES_DIR = join(__dirname, "templates");
202
+ async function loadTemplate(name, vars) {
203
+ const content = await readFile(join(TEMPLATES_DIR, name), "utf-8");
204
+ if (!vars || Object.keys(vars).length === 0) {
205
+ return content;
206
+ }
207
+ return Object.entries(vars).reduce(
208
+ (result, [key, value]) => result.replaceAll(`{{${key}}}`, value),
209
+ content
210
+ );
211
+ }
212
+
213
+ // src/scaffold/scaffold.ts
195
214
  async function scaffoldProject(config) {
196
215
  const { targetDir } = config;
197
216
  const filesCreated = [];
@@ -214,12 +233,12 @@ Use --force to scaffold anyway (existing files will be skipped).`,
214
233
  "tests/forms"
215
234
  ];
216
235
  for (const dir of dirs) {
217
- await mkdir2(join(targetDir, dir), { recursive: true });
236
+ await mkdir2(join2(targetDir, dir), { recursive: true });
218
237
  }
219
- const templates = generateTemplates(config);
238
+ const templates = await generateTemplates(config);
220
239
  for (const [relativePath, content] of templates) {
221
- const absolutePath = join(targetDir, relativePath);
222
- await mkdir2(join(absolutePath, ".."), { recursive: true });
240
+ const absolutePath = join2(targetDir, relativePath);
241
+ await mkdir2(join2(absolutePath, ".."), { recursive: true });
223
242
  if (config.force) {
224
243
  try {
225
244
  await access(absolutePath);
@@ -233,21 +252,22 @@ Use --force to scaffold anyway (existing files will be skipped).`,
233
252
  }
234
253
  return { filesCreated, warnings };
235
254
  }
236
- function generateTemplates(config) {
255
+ async function generateTemplates(config) {
237
256
  const { projectName, prefix, namespace } = config;
238
257
  const lowerPrefix = prefix.toLowerCase();
258
+ const namespaceVars = { namespace };
239
259
  return [
240
260
  ["package.json", generatePackageJson(projectName)],
241
261
  ["tsconfig.json", generateTsConfig()],
242
262
  ["xrmforge.config.json", generateXrmForgeConfig(lowerPrefix, namespace)],
243
- ["vitest.config.ts", generateVitestConfig()],
244
- [".gitignore", generateGitIgnore()],
245
- ["AGENT.md", generateAgentMd()],
246
- ["src/forms/example-form.ts", generateExampleForm(namespace)],
263
+ ["vitest.config.ts", await loadTemplate("vitest.config.ts")],
264
+ [".gitignore", await loadTemplate("gitignore")],
265
+ ["AGENT.md", await loadTemplate("AGENT.md")],
266
+ ["src/forms/example-form.ts", await loadTemplate("example-form.ts", namespaceVars)],
247
267
  ["typings/.gitkeep", ""],
248
- ["tests/forms/example-form.test.ts", generateExampleTest(namespace)],
249
- [".github/workflows/ci.yml", generateGitHubActionsCI()],
250
- ["azure-pipelines.yml", generateAzureDevOpsPipeline()]
268
+ ["tests/forms/example-form.test.ts", await loadTemplate("example-form.test.ts", namespaceVars)],
269
+ [".github/workflows/ci.yml", await loadTemplate("github-actions-ci.yml")],
270
+ ["azure-pipelines.yml", await loadTemplate("azure-pipelines.yml")]
251
271
  ];
252
272
  }
253
273
  function generatePackageJson(projectName) {
@@ -266,9 +286,9 @@ function generatePackageJson(projectName) {
266
286
  },
267
287
  devDependencies: {
268
288
  "@types/xrm": "^9.0.90",
269
- "@xrmforge/cli": "^0.3.0",
270
- "@xrmforge/testing": "^0.1.0",
271
- "@xrmforge/formhelpers": "^0.1.0",
289
+ "@xrmforge/cli": "^0.4.3",
290
+ "@xrmforge/testing": "^0.2.0",
291
+ "@xrmforge/helpers": "^0.1.0",
272
292
  typescript: "^5.7.0",
273
293
  vitest: "^3.0.0"
274
294
  }
@@ -314,366 +334,6 @@ function generateXrmForgeConfig(prefix, namespace) {
314
334
  };
315
335
  return JSON.stringify(config, null, 2) + "\n";
316
336
  }
317
- function generateVitestConfig() {
318
- return `import { defineConfig } from 'vitest/config';
319
-
320
- export default defineConfig({
321
- test: {
322
- globals: false,
323
- include: ['tests/**/*.test.ts'],
324
- },
325
- });
326
- `;
327
- }
328
- function generateGitIgnore() {
329
- return `# Dependencies
330
- node_modules/
331
-
332
- # Build output
333
- dist/
334
-
335
- # XrmForge cache
336
- .xrmforge/
337
-
338
- # IDE
339
- .vscode/settings.json
340
- .idea/
341
-
342
- # OS
343
- .DS_Store
344
- Thumbs.db
345
-
346
- # Logs
347
- *.log
348
- `;
349
- }
350
- function generateAgentMd() {
351
- return `# XrmForge - AI Agent Instructions
352
-
353
- This file helps AI coding assistants write optimal Dynamics 365 form scripts.
354
-
355
- ## Packages
356
-
357
- - \`@xrmforge/typegen\` - Generates typed declarations from Dataverse metadata
358
- - \`@xrmforge/testing\` - Type-safe form mocks: createFormMock(), fireOnChange()
359
- - \`@xrmforge/formhelpers\` - typedForm() proxy for direct field access
360
- - \`@xrmforge/devkit\` - esbuild IIFE bundles via xrmforge build
361
- - \`@xrmforge/eslint-plugin\` - D365-specific ESLint rules
362
-
363
- ## Generated Types (typings/ directory)
364
-
365
- Run \`xrmforge generate\` to create:
366
- - \`typings/forms/{entity}.d.ts\` - Form interface + Fields/Tabs/Sections/Subgrids enums
367
- - \`typings/optionsets/{entity}.d.ts\` - OptionSet const enums
368
- - \`typings/entities/{entity}.d.ts\` - Entity interface + Fields enum
369
- - \`typings/entity-names.d.ts\` - EntityNames const enum
370
-
371
- ## Rules: Always
372
-
373
- 1. **Fields Enum** for getAttribute/getControl (not raw strings):
374
- \`form.getAttribute(Fields.AccountName)\` not \`form.getAttribute("name")\`
375
-
376
- 2. **OptionSet Enum** for comparisons (not magic numbers):
377
- \`status === StatusCode.Active\` not \`status === 0\`
378
-
379
- 3. **Cast formContext** to generated form interface:
380
- \`const form = ctx.getFormContext() as AccountMainForm;\`
381
-
382
- 4. **EntityNames Enum** for Web API calls:
383
- \`Xrm.WebApi.retrieveRecord(EntityNames.Account, id)\`
384
-
385
- 5. **parseLookup()** from @xrmforge/typegen/helpers for lookup values
386
- IMPORTANT: Use \`@xrmforge/typegen/helpers\` (not \`@xrmforge/typegen\`) in browser code.
387
- The main entry point pulls in Node.js dependencies that break esbuild bundles.
388
-
389
- 6. **select()** from @xrmforge/typegen/helpers for $select queries
390
-
391
- 7. **createFormMock()** from @xrmforge/testing for tests
392
-
393
- 8. **Module exports** (not window/global assignments). esbuild globalName handles namespacing.
394
-
395
- 9. **Tabs/Sections/Subgrids Enums** for UI access
396
-
397
- 10. **Error handling** in all async event handlers (try/catch)
398
-
399
- ## Rules: Never
400
-
401
- - Never \`getAttribute("raw_string")\` when Fields enum exists
402
- - Never magic numbers for OptionSet values
403
- - Never \`Xrm.Page\` (deprecated since D365 v9.0)
404
- - Never synchronous XMLHttpRequest
405
- - Never \`eval()\`
406
- - Never \`window.X = ...\` (use module exports)
407
-
408
- ## Before/After Examples
409
-
410
- ### Field Access
411
- \`\`\`typescript
412
- // BEFORE: formContext.getAttribute("name").getValue()
413
- // AFTER:
414
- import { AccountMainFormFieldsEnum as Fields } from '../typings/forms/account';
415
- const form = ctx.getFormContext() as AccountMainForm;
416
- form.getAttribute(Fields.AccountName).getValue(); // StringAttribute, typed
417
- \`\`\`
418
-
419
- ### OptionSet Comparison
420
- \`\`\`typescript
421
- // BEFORE: if (status.getValue() === 595300002) { ... }
422
- // AFTER:
423
- import { StatusCode } from '../typings/optionsets/invoice';
424
- if (status.getValue() === StatusCode.Gebucht) { ... }
425
- \`\`\`
426
-
427
- ### Testing
428
- \`\`\`typescript
429
- import { createFormMock } from '@xrmforge/testing';
430
- const mock = createFormMock<AccountMainForm, AccountMainFormMockValues>({
431
- name: 'Test', statuscode: 0
432
- });
433
- onLoad(mock.executionContext);
434
- expect(mock.formContext.getControl('revenue').getVisible()).toBe(true);
435
- \`\`\`
436
-
437
- ## File Structure
438
-
439
- \`\`\`
440
- src/forms/{entity}-form.ts - Form scripts (one per entity)
441
- src/shared/{name}.ts - Shared utilities
442
- typings/ - Generated types (do not edit manually)
443
- tests/forms/{entity}.test.ts - Tests
444
- xrmforge.config.json - Build config
445
- \`\`\`
446
-
447
- ## Pattern Recognition: Legacy to XrmForge
448
-
449
- When you see these patterns in legacy code, apply the XrmForge replacement:
450
-
451
- | Legacy Pattern | XrmForge Replacement |
452
- |---|---|
453
- | \`getAttribute("name")\` | \`getAttribute(Fields.Name)\` |
454
- | \`getControl("name")\` | \`getControl(Fields.Name)\` |
455
- | \`getValue() === 595300000\` | \`getValue() === OptionSets.StatusCode.Active\` |
456
- | \`Xrm.WebApi.retrieveRecord("account", id)\` | \`Xrm.WebApi.retrieveRecord(EntityNames.Account, id)\` |
457
- | \`"?$select=name,revenue"\` | \`select(Fields.Name, Fields.Revenue)\` (from typegen/helpers) |
458
- | \`value[0].id.replace("{","")...\` | \`parseLookup(form.getAttribute(Fields.X))\` (from typegen/helpers) |
459
- | \`Xrm.Page.getAttribute(...)\` | \`formContext.getAttribute(...)\` |
460
- | \`var formContext\` (global) | \`const form = ctx.getFormContext()\` (parameter) |
461
- | \`function form_OnLoad(ctx)\` | \`export function onLoad(ctx: Xrm.Events.EventContext)\` |
462
- | \`.then(success, error)\` | \`async/await with try/catch\` |
463
-
464
- ### Creating OptionSet Enums from Legacy Magic Numbers
465
-
466
- When you find magic numbers like \`getValue() === 105710002\` in legacy code:
467
- 1. Search the file for ALL numeric comparisons with getValue()
468
- 2. Create a const enum in typings/optionsets/ with descriptive names
469
- 3. Import and use the enum instead of the number
470
-
471
- Example:
472
- \`\`\`typescript
473
- // typings/optionsets/invoice.ts
474
- export const enum InvoiceStatusCode {
475
- Neu = 1,
476
- Versendet = 105710000,
477
- Abgeschlossen = 105710001,
478
- Gebucht = 105710002,
479
- }
480
-
481
- // In the form script:
482
- import { InvoiceStatusCode } from '../../typings/optionsets/invoice';
483
- if (status.getValue() === InvoiceStatusCode.Gebucht) { ... }
484
- \`\`\`
485
-
486
- ## Testing with Global Xrm Mock
487
-
488
- Use \`setupXrmMock()\` from @xrmforge/testing to mock the global Xrm namespace:
489
- \`\`\`typescript
490
- import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
491
-
492
- beforeEach(() => setupXrmMock());
493
- afterEach(() => teardownXrmMock());
494
-
495
- // Override specific WebApi methods:
496
- setupXrmMock({
497
- webApiOverrides: {
498
- retrieveMultipleRecords: async () => ({ entities: [{ name: 'Test' }] }),
499
- },
500
- });
501
- \`\`\`
502
-
503
- ## Build
504
-
505
- \`\`\`bash
506
- npx xrmforge build # IIFE bundles for D365
507
- npx xrmforge build --watch # Watch mode (~10ms rebuilds)
508
- \`\`\`
509
-
510
- ## @types/xrm Pitfalls (known issues)
511
-
512
- When creating manual typings without \`xrmforge generate\`:
513
-
514
- 1. **Form Interface:** Do NOT use \`interface extends Xrm.FormContext\` (getAttribute overload conflicts).
515
- Use \`Omit\` pattern instead:
516
- \`\`\`typescript
517
- interface AccountMainForm extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {
518
- getAttribute(name: Fields.AccountName): Xrm.Attributes.StringAttribute;
519
- getAttribute(name: string): Xrm.Attributes.Attribute;
520
- // ...
521
- }
522
- \`\`\`
523
-
524
- 2. **AlertDialogResponse** does NOT exist in @types/xrm. Use \`Xrm.Async.PromiseLike<void>\`.
525
-
526
- 3. **ConfirmDialogResponse** does NOT exist. Use \`Xrm.Navigation.ConfirmResult\`.
527
-
528
- 4. **setNotification()** requires 2 arguments: (message, uniqueId).
529
-
530
- 5. **openFile()** requires \`fileSize\` property in FileDetails.
531
-
532
- 6. **const enum in .d.ts files** cannot be imported at runtime by test frameworks.
533
- For manual typings, use regular \`enum\` in \`.ts\` files (not \`.d.ts\`).
534
-
535
- ## Full Migration Guide
536
-
537
- See: https://www.npmjs.com/package/@xrmforge/typegen (MIGRATION.md)
538
- `;
539
- }
540
- function generateExampleForm(namespace) {
541
- return `/**
542
- * Example Form Script for Dynamics 365.
543
- *
544
- * Register in D365 as: ${namespace}.Example.onLoad
545
- *
546
- * Replace this with your actual form logic.
547
- */
548
-
549
- /**
550
- * Called when the form loads.
551
- */
552
- export function onLoad(executionContext: Xrm.Events.EventContext): void {
553
- const formContext = executionContext.getFormContext();
554
-
555
- // Example: show a notification on the form
556
- formContext.ui.setFormNotification(
557
- 'Form loaded successfully',
558
- 'INFO',
559
- 'example-notification',
560
- );
561
-
562
- // Example: read a field value
563
- const nameAttr = formContext.getAttribute('name');
564
- if (nameAttr) {
565
- const value = nameAttr.getValue();
566
- console.log('Name field value:', value);
567
- }
568
- }
569
-
570
- /**
571
- * Called when the form is saved.
572
- */
573
- export function onSave(executionContext: Xrm.Events.EventContext): void {
574
- const formContext = executionContext.getFormContext();
575
-
576
- // Clear the notification on save
577
- formContext.ui.clearFormNotification('example-notification');
578
- }
579
- `;
580
- }
581
- function generateExampleTest(namespace) {
582
- return `import { describe, it, expect } from 'vitest';
583
-
584
- /**
585
- * Example test for the form script.
586
- *
587
- * Uses @xrmforge/testing for type-safe mocking once you have
588
- * generated types. For now, this is a placeholder.
589
- */
590
- describe('${namespace}.Example', () => {
591
- it('should export onLoad function', async () => {
592
- const mod = await import('../../src/forms/example-form.js');
593
- expect(typeof mod.onLoad).toBe('function');
594
- });
595
-
596
- it('should export onSave function', async () => {
597
- const mod = await import('../../src/forms/example-form.js');
598
- expect(typeof mod.onSave).toBe('function');
599
- });
600
- });
601
- `;
602
- }
603
- function generateGitHubActionsCI() {
604
- return `name: CI
605
-
606
- on:
607
- push:
608
- branches: [main]
609
- pull_request:
610
- branches: [main]
611
-
612
- jobs:
613
- build:
614
- runs-on: ubuntu-latest
615
-
616
- steps:
617
- - uses: actions/checkout@v4
618
-
619
- - uses: actions/setup-node@v4
620
- with:
621
- node-version: 20
622
-
623
- - run: npm ci
624
-
625
- - name: Generate types from Dataverse
626
- run: npx xrmforge generate --from-config
627
- env:
628
- XRMFORGE_CLIENT_ID: \${{ secrets.XRMFORGE_CLIENT_ID }}
629
- XRMFORGE_CLIENT_SECRET: \${{ secrets.XRMFORGE_CLIENT_SECRET }}
630
- XRMFORGE_TENANT_ID: \${{ secrets.XRMFORGE_TENANT_ID }}
631
-
632
- - name: Type check
633
- run: npx tsc --noEmit
634
-
635
- - name: Test
636
- run: npx vitest run
637
-
638
- - name: Build WebResources
639
- run: npx xrmforge build
640
- `;
641
- }
642
- function generateAzureDevOpsPipeline() {
643
- return `trigger:
644
- branches:
645
- include:
646
- - main
647
-
648
- pool:
649
- vmImage: 'ubuntu-latest'
650
-
651
- steps:
652
- - task: NodeTool@0
653
- inputs:
654
- versionSpec: '20.x'
655
- displayName: 'Install Node.js'
656
-
657
- - script: npm ci
658
- displayName: 'Install dependencies'
659
-
660
- - script: npx xrmforge generate --from-config
661
- displayName: 'Generate types from Dataverse'
662
- env:
663
- XRMFORGE_CLIENT_ID: $(XRMFORGE_CLIENT_ID)
664
- XRMFORGE_CLIENT_SECRET: $(XRMFORGE_CLIENT_SECRET)
665
- XRMFORGE_TENANT_ID: $(XRMFORGE_TENANT_ID)
666
-
667
- - script: npx tsc --noEmit
668
- displayName: 'Type check'
669
-
670
- - script: npx vitest run
671
- displayName: 'Test'
672
-
673
- - script: npx xrmforge build
674
- displayName: 'Build WebResources'
675
- `;
676
- }
677
337
  export {
678
338
  BuildError,
679
339
  BuildErrorCode,
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"],"sourcesContent":["/**\n * @xrmforge/devkit - Build Error Types\n *\n * Structured error types for build operations.\n */\n\nexport enum BuildErrorCode {\n /** Build configuration is invalid or missing required fields */\n CONFIG_INVALID = 'BUILD_6001',\n /** Entry point file not found on disk */\n ENTRY_NOT_FOUND = 'BUILD_6002',\n /** esbuild compilation failed (syntax errors, missing imports) */\n BUILD_FAILED = 'BUILD_6003',\n /** Error in watch mode */\n WATCH_ERROR = 'BUILD_6004',\n}\n\n/**\n * Error class for build operations.\n * Carries a machine-readable code and optional context for debugging.\n */\nexport class BuildError extends Error {\n public readonly code: BuildErrorCode;\n public readonly context: Record<string, unknown>;\n\n constructor(code: BuildErrorCode, message: string, context: Record<string, unknown> = {}) {\n super(`[${code}] ${message}`);\n this.name = 'BuildError';\n this.code = code;\n this.context = context;\n\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, BuildError);\n }\n }\n}\n","/**\n * @xrmforge/devkit - Build Configuration\n *\n * Types and validation for the `build` section in xrmforge.config.json.\n */\n\nimport { BuildError, BuildErrorCode } from './errors.js';\n\n/** A single build entry (one WebResource) */\nexport interface BuildEntry {\n /** Relative path to the TypeScript source file */\n input: string;\n /** Global namespace for D365 form event binding (e.g. \"Contoso.Account\") */\n namespace: string;\n /** Optional output filename relative to outDir (defaults to entry key + \".js\") */\n out?: string;\n}\n\n/** Build configuration for WebResource bundling */\nexport interface BuildConfig {\n /** Bundler to use (currently only \"esbuild\") */\n bundler?: 'esbuild';\n /** Named build entries: key = entry name, value = entry config */\n entries: Record<string, BuildEntry>;\n /** Output directory for built bundles (default: \"./dist\") */\n outDir?: string;\n /** JavaScript target version (default: \"es2020\") */\n target?: string;\n /** Generate source maps (default: true) */\n sourcemap?: boolean;\n /** Minify output (default: false) */\n minify?: boolean;\n /** Additional modules to exclude from bundling */\n external?: string[];\n}\n\n/** Fully resolved build config with all defaults applied */\nexport interface ResolvedBuildConfig {\n bundler: 'esbuild';\n entries: Record<string, BuildEntry>;\n outDir: string;\n target: string;\n sourcemap: boolean;\n minify: boolean;\n external: string[];\n}\n\n/**\n * Validate a raw build config object.\n * Throws BuildError with CONFIG_INVALID if any required field is missing or invalid.\n */\nexport function validateBuildConfig(raw: unknown): BuildConfig {\n if (!raw || typeof raw !== 'object') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration must be an object.',\n );\n }\n\n const config = raw as Record<string, unknown>;\n\n // entries: required, non-empty object\n if (!config['entries'] || typeof config['entries'] !== 'object' || Array.isArray(config['entries'])) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration requires an \"entries\" object with at least one entry.',\n );\n }\n\n const entries = config['entries'] as Record<string, unknown>;\n const entryNames = Object.keys(entries);\n\n if (entryNames.length === 0) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration requires at least one entry in \"entries\".',\n );\n }\n\n for (const name of entryNames) {\n const entry = entries[name] as Record<string, unknown> | undefined;\n\n if (!entry || typeof entry !== 'object') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" must be an object with \"input\" and \"namespace\".`,\n { entry: name },\n );\n }\n\n if (!entry['input'] || typeof entry['input'] !== 'string') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" requires an \"input\" field (path to .ts source file).`,\n { entry: name },\n );\n }\n\n if (!entry['namespace'] || typeof entry['namespace'] !== 'string') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" requires a \"namespace\" field (e.g. \"Contoso.Account\").`,\n { entry: name },\n );\n }\n }\n\n // bundler: optional, must be \"esbuild\" if set\n if (config['bundler'] !== undefined && config['bundler'] !== 'esbuild') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Unsupported bundler: \"${String(config['bundler'])}\". Currently only \"esbuild\" is supported.`,\n { bundler: config['bundler'] },\n );\n }\n\n return config as unknown as BuildConfig;\n}\n\n/**\n * Apply default values to a validated build config.\n */\nexport function resolveBuildConfig(config: BuildConfig): ResolvedBuildConfig {\n return {\n bundler: config.bundler ?? 'esbuild',\n entries: config.entries,\n outDir: config.outDir ?? './dist',\n target: config.target ?? 'es2020',\n sourcemap: config.sourcemap ?? true,\n minify: config.minify ?? false,\n external: config.external ?? [],\n };\n}\n","/**\n * @xrmforge/devkit - esbuild Builder\n *\n * Builds D365 WebResources as IIFE bundles with named globals.\n * Abstracts esbuild so users never write esbuild config.\n */\n\nimport * as esbuild from 'esbuild';\nimport { stat, mkdir } from 'node:fs/promises';\nimport { resolve, dirname } from 'node:path';\nimport type { BuildConfig } from '../config.js';\nimport { resolveBuildConfig } from '../config.js';\nimport { BuildError, BuildErrorCode } from '../errors.js';\nimport type { BuildResult, BuildResultEntry } from './types.js';\n\n/**\n * Build all entries defined in the config as IIFE bundles.\n *\n * @param config - Validated build configuration\n * @param cwd - Working directory for resolving relative paths (defaults to process.cwd())\n * @returns Build result with per-entry details\n */\nexport async function build(config: BuildConfig, cwd?: string): Promise<BuildResult> {\n const startTime = Date.now();\n const resolved = resolveBuildConfig(config);\n const basedir = cwd ?? process.cwd();\n const outDir = resolve(basedir, resolved.outDir);\n\n // Ensure output directory exists\n await mkdir(outDir, { recursive: true });\n\n const entryNames = Object.keys(resolved.entries);\n const results: BuildResultEntry[] = [];\n const errors: string[] = [];\n const warnings: string[] = [];\n\n // Build all entries in parallel\n const settled = await Promise.allSettled(\n entryNames.map(async (name) => {\n const entry = resolved.entries[name]!;\n const entryStart = Date.now();\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\n\n // Ensure subdirectory exists for custom out paths\n await mkdir(dirname(outFile), { recursive: true });\n\n const buildOptions: esbuild.BuildOptions = {\n entryPoints: [resolve(basedir, entry.input)],\n bundle: true,\n format: 'iife',\n globalName: entry.namespace,\n outfile: outFile,\n target: [resolved.target],\n minify: resolved.minify,\n sourcemap: resolved.sourcemap,\n treeShaking: true,\n logLevel: 'silent',\n external: resolved.external,\n };\n\n const result = await esbuild.build(buildOptions);\n\n // Collect esbuild warnings\n for (const w of result.warnings) {\n warnings.push(`[${name}] ${w.text}`);\n }\n\n // Get output file size\n const stats = await stat(outFile);\n\n return {\n name,\n outFile,\n sizeBytes: stats.size,\n durationMs: Date.now() - entryStart,\n } satisfies BuildResultEntry;\n }),\n );\n\n for (let i = 0; i < settled.length; i++) {\n const outcome = settled[i]!;\n const name = entryNames[i]!;\n\n if (outcome.status === 'fulfilled') {\n results.push(outcome.value);\n } else {\n const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);\n // Distinguish \"file not found\" from other build errors\n if (errorMsg.includes('Could not resolve') || errorMsg.includes('ENOENT')) {\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.ENTRY_NOT_FOUND, errorMsg, { entry: name }).message}`);\n } else {\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.BUILD_FAILED, errorMsg, { entry: name }).message}`);\n }\n }\n }\n\n return {\n entries: results,\n totalDurationMs: Date.now() - startTime,\n errors,\n warnings,\n };\n}\n\n/**\n * Start watch mode for all entries.\n * Returns a dispose function to stop watching.\n *\n * @param config - Validated build configuration\n * @param options - Watch options\n * @returns Object with dispose() to stop watching\n */\nexport async function watch(\n config: BuildConfig,\n options?: {\n cwd?: string;\n onRebuild?: (result: BuildResult) => void;\n },\n): Promise<{ dispose: () => Promise<void> }> {\n const resolved = resolveBuildConfig(config);\n const basedir = options?.cwd ?? process.cwd();\n const outDir = resolve(basedir, resolved.outDir);\n\n await mkdir(outDir, { recursive: true });\n\n const contexts: esbuild.BuildContext[] = [];\n\n for (const [name, entry] of Object.entries(resolved.entries)) {\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\n await mkdir(dirname(outFile), { recursive: true });\n\n const ctx = await esbuild.context({\n entryPoints: [resolve(basedir, entry.input)],\n bundle: true,\n format: 'iife',\n globalName: entry.namespace,\n outfile: outFile,\n target: [resolved.target],\n minify: resolved.minify,\n sourcemap: resolved.sourcemap,\n treeShaking: true,\n logLevel: 'silent',\n external: resolved.external,\n });\n\n contexts.push(ctx);\n await ctx.watch();\n }\n\n return {\n dispose: async () => {\n for (const ctx of contexts) {\n await ctx.dispose();\n }\n },\n };\n}\n","/**\n * @xrmforge/devkit - Project Scaffolding\n *\n * Generates a complete D365 form scripting project from templates.\n */\n\nimport { mkdir, writeFile, readdir, access } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { ScaffoldConfig, ScaffoldResult } from './types.js';\nimport { BuildError, BuildErrorCode } from '../errors.js';\n\n/**\n * Scaffold a new D365 form scripting project.\n *\n * Creates a complete project structure with package.json, tsconfig,\n * xrmforge.config.json, example form script, and test file.\n *\n * @param config - Scaffold configuration\n * @returns List of created files and any warnings\n * @throws {BuildError} if target directory is not empty (unless files are only dotfiles)\n */\nexport async function scaffoldProject(config: ScaffoldConfig): Promise<ScaffoldResult> {\n const { targetDir } = config;\n const filesCreated: string[] = [];\n const warnings: string[] = [];\n\n // Ensure target directory exists\n await mkdir(targetDir, { recursive: true });\n\n // Check if directory is empty (ignore dotfiles and node_modules)\n const existing = await readdir(targetDir);\n const nonDotFiles = existing.filter((f) => !f.startsWith('.') && f !== 'node_modules');\n if (nonDotFiles.length > 0 && !config.force) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Target directory is not empty: ${targetDir}\\n` +\n `Found: ${nonDotFiles.slice(0, 5).join(', ')}${nonDotFiles.length > 5 ? '...' : ''}\\n` +\n `Use --force to scaffold anyway (existing files will be skipped).`,\n { targetDir, existingFiles: nonDotFiles },\n );\n }\n\n // Create directory structure\n const dirs = [\n 'src/forms',\n 'typings',\n 'tests/forms',\n ];\n\n for (const dir of dirs) {\n await mkdir(join(targetDir, dir), { recursive: true });\n }\n\n // Generate and write all template files\n const templates = generateTemplates(config);\n\n for (const [relativePath, content] of templates) {\n const absolutePath = join(targetDir, relativePath);\n await mkdir(join(absolutePath, '..'), { recursive: true });\n\n // In force mode: skip files that already exist\n if (config.force) {\n try {\n await access(absolutePath);\n warnings.push(`Skipped ${relativePath} (already exists)`);\n continue;\n } catch {\n // File doesn't exist, proceed with write\n }\n }\n\n await writeFile(absolutePath, content, 'utf-8');\n filesCreated.push(relativePath);\n }\n\n return { filesCreated, warnings };\n}\n\n/**\n * Generate all template file contents.\n * Returns an array of [relativePath, content] tuples.\n */\nfunction generateTemplates(config: ScaffoldConfig): Array<[string, string]> {\n const { projectName, prefix, namespace } = config;\n const lowerPrefix = prefix.toLowerCase();\n\n return [\n ['package.json', generatePackageJson(projectName)],\n ['tsconfig.json', generateTsConfig()],\n ['xrmforge.config.json', generateXrmForgeConfig(lowerPrefix, namespace)],\n ['vitest.config.ts', generateVitestConfig()],\n ['.gitignore', generateGitIgnore()],\n ['AGENT.md', generateAgentMd()],\n ['src/forms/example-form.ts', generateExampleForm(namespace)],\n ['typings/.gitkeep', ''],\n ['tests/forms/example-form.test.ts', generateExampleTest(namespace)],\n ['.github/workflows/ci.yml', generateGitHubActionsCI()],\n ['azure-pipelines.yml', generateAzureDevOpsPipeline()],\n ];\n}\n\nfunction generatePackageJson(projectName: string): string {\n const pkg = {\n name: projectName,\n version: '0.1.0',\n private: true,\n type: 'module',\n scripts: {\n generate: 'xrmforge generate',\n typecheck: 'tsc --noEmit',\n build: 'xrmforge build',\n watch: 'xrmforge build --watch',\n test: 'vitest run',\n 'test:watch': 'vitest',\n },\n devDependencies: {\n '@types/xrm': '^9.0.90',\n '@xrmforge/cli': '^0.3.0',\n '@xrmforge/testing': '^0.1.0',\n '@xrmforge/formhelpers': '^0.1.0',\n typescript: '^5.7.0',\n vitest: '^3.0.0',\n },\n };\n return JSON.stringify(pkg, null, 2) + '\\n';\n}\n\nfunction generateTsConfig(): string {\n const config = {\n compilerOptions: {\n target: 'ES2020',\n module: 'ESNext',\n moduleResolution: 'bundler',\n lib: ['ES2020', 'DOM'],\n types: ['xrm'],\n strict: true,\n noEmit: true,\n skipLibCheck: false,\n esModuleInterop: true,\n },\n include: [\n 'src/**/*.ts',\n 'typings/**/*.d.ts',\n 'typings/**/*.ts',\n ],\n };\n return JSON.stringify(config, null, 2) + '\\n';\n}\n\nfunction generateXrmForgeConfig(prefix: string, namespace: string): string {\n const config = {\n build: {\n outDir: `./dist/${prefix}_/JS`,\n target: 'es2020',\n sourcemap: true,\n minify: true,\n entries: {\n example_form: {\n input: './src/forms/example-form.ts',\n namespace: `${namespace}.Example`,\n out: 'Example/OnLoad.js',\n },\n },\n },\n };\n return JSON.stringify(config, null, 2) + '\\n';\n}\n\nfunction generateVitestConfig(): string {\n return `import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n test: {\n globals: false,\n include: ['tests/**/*.test.ts'],\n },\n});\n`;\n}\n\nfunction generateGitIgnore(): string {\n return `# Dependencies\nnode_modules/\n\n# Build output\ndist/\n\n# XrmForge cache\n.xrmforge/\n\n# IDE\n.vscode/settings.json\n.idea/\n\n# OS\n.DS_Store\nThumbs.db\n\n# Logs\n*.log\n`;\n}\n\nfunction generateAgentMd(): string {\n return `# XrmForge - AI Agent Instructions\n\nThis file helps AI coding assistants write optimal Dynamics 365 form scripts.\n\n## Packages\n\n- \\`@xrmforge/typegen\\` - Generates typed declarations from Dataverse metadata\n- \\`@xrmforge/testing\\` - Type-safe form mocks: createFormMock(), fireOnChange()\n- \\`@xrmforge/formhelpers\\` - typedForm() proxy for direct field access\n- \\`@xrmforge/devkit\\` - esbuild IIFE bundles via xrmforge build\n- \\`@xrmforge/eslint-plugin\\` - D365-specific ESLint rules\n\n## Generated Types (typings/ directory)\n\nRun \\`xrmforge generate\\` to create:\n- \\`typings/forms/{entity}.d.ts\\` - Form interface + Fields/Tabs/Sections/Subgrids enums\n- \\`typings/optionsets/{entity}.d.ts\\` - OptionSet const enums\n- \\`typings/entities/{entity}.d.ts\\` - Entity interface + Fields enum\n- \\`typings/entity-names.d.ts\\` - EntityNames const enum\n\n## Rules: Always\n\n1. **Fields Enum** for getAttribute/getControl (not raw strings):\n \\`form.getAttribute(Fields.AccountName)\\` not \\`form.getAttribute(\"name\")\\`\n\n2. **OptionSet Enum** for comparisons (not magic numbers):\n \\`status === StatusCode.Active\\` not \\`status === 0\\`\n\n3. **Cast formContext** to generated form interface:\n \\`const form = ctx.getFormContext() as AccountMainForm;\\`\n\n4. **EntityNames Enum** for Web API calls:\n \\`Xrm.WebApi.retrieveRecord(EntityNames.Account, id)\\`\n\n5. **parseLookup()** from @xrmforge/typegen/helpers for lookup values\n IMPORTANT: Use \\`@xrmforge/typegen/helpers\\` (not \\`@xrmforge/typegen\\`) in browser code.\n The main entry point pulls in Node.js dependencies that break esbuild bundles.\n\n6. **select()** from @xrmforge/typegen/helpers for $select queries\n\n7. **createFormMock()** from @xrmforge/testing for tests\n\n8. **Module exports** (not window/global assignments). esbuild globalName handles namespacing.\n\n9. **Tabs/Sections/Subgrids Enums** for UI access\n\n10. **Error handling** in all async event handlers (try/catch)\n\n## Rules: Never\n\n- Never \\`getAttribute(\"raw_string\")\\` when Fields enum exists\n- Never magic numbers for OptionSet values\n- Never \\`Xrm.Page\\` (deprecated since D365 v9.0)\n- Never synchronous XMLHttpRequest\n- Never \\`eval()\\`\n- Never \\`window.X = ...\\` (use module exports)\n\n## Before/After Examples\n\n### Field Access\n\\`\\`\\`typescript\n// BEFORE: formContext.getAttribute(\"name\").getValue()\n// AFTER:\nimport { AccountMainFormFieldsEnum as Fields } from '../typings/forms/account';\nconst form = ctx.getFormContext() as AccountMainForm;\nform.getAttribute(Fields.AccountName).getValue(); // StringAttribute, typed\n\\`\\`\\`\n\n### OptionSet Comparison\n\\`\\`\\`typescript\n// BEFORE: if (status.getValue() === 595300002) { ... }\n// AFTER:\nimport { StatusCode } from '../typings/optionsets/invoice';\nif (status.getValue() === StatusCode.Gebucht) { ... }\n\\`\\`\\`\n\n### Testing\n\\`\\`\\`typescript\nimport { createFormMock } from '@xrmforge/testing';\nconst mock = createFormMock<AccountMainForm, AccountMainFormMockValues>({\n name: 'Test', statuscode: 0\n});\nonLoad(mock.executionContext);\nexpect(mock.formContext.getControl('revenue').getVisible()).toBe(true);\n\\`\\`\\`\n\n## File Structure\n\n\\`\\`\\`\nsrc/forms/{entity}-form.ts - Form scripts (one per entity)\nsrc/shared/{name}.ts - Shared utilities\ntypings/ - Generated types (do not edit manually)\ntests/forms/{entity}.test.ts - Tests\nxrmforge.config.json - Build config\n\\`\\`\\`\n\n## Pattern Recognition: Legacy to XrmForge\n\nWhen you see these patterns in legacy code, apply the XrmForge replacement:\n\n| Legacy Pattern | XrmForge Replacement |\n|---|---|\n| \\`getAttribute(\"name\")\\` | \\`getAttribute(Fields.Name)\\` |\n| \\`getControl(\"name\")\\` | \\`getControl(Fields.Name)\\` |\n| \\`getValue() === 595300000\\` | \\`getValue() === OptionSets.StatusCode.Active\\` |\n| \\`Xrm.WebApi.retrieveRecord(\"account\", id)\\` | \\`Xrm.WebApi.retrieveRecord(EntityNames.Account, id)\\` |\n| \\`\"?$select=name,revenue\"\\` | \\`select(Fields.Name, Fields.Revenue)\\` (from typegen/helpers) |\n| \\`value[0].id.replace(\"{\",\"\")...\\` | \\`parseLookup(form.getAttribute(Fields.X))\\` (from typegen/helpers) |\n| \\`Xrm.Page.getAttribute(...)\\` | \\`formContext.getAttribute(...)\\` |\n| \\`var formContext\\` (global) | \\`const form = ctx.getFormContext()\\` (parameter) |\n| \\`function form_OnLoad(ctx)\\` | \\`export function onLoad(ctx: Xrm.Events.EventContext)\\` |\n| \\`.then(success, error)\\` | \\`async/await with try/catch\\` |\n\n### Creating OptionSet Enums from Legacy Magic Numbers\n\nWhen you find magic numbers like \\`getValue() === 105710002\\` in legacy code:\n1. Search the file for ALL numeric comparisons with getValue()\n2. Create a const enum in typings/optionsets/ with descriptive names\n3. Import and use the enum instead of the number\n\nExample:\n\\`\\`\\`typescript\n// typings/optionsets/invoice.ts\nexport const enum InvoiceStatusCode {\n Neu = 1,\n Versendet = 105710000,\n Abgeschlossen = 105710001,\n Gebucht = 105710002,\n}\n\n// In the form script:\nimport { InvoiceStatusCode } from '../../typings/optionsets/invoice';\nif (status.getValue() === InvoiceStatusCode.Gebucht) { ... }\n\\`\\`\\`\n\n## Testing with Global Xrm Mock\n\nUse \\`setupXrmMock()\\` from @xrmforge/testing to mock the global Xrm namespace:\n\\`\\`\\`typescript\nimport { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';\n\nbeforeEach(() => setupXrmMock());\nafterEach(() => teardownXrmMock());\n\n// Override specific WebApi methods:\nsetupXrmMock({\n webApiOverrides: {\n retrieveMultipleRecords: async () => ({ entities: [{ name: 'Test' }] }),\n },\n});\n\\`\\`\\`\n\n## Build\n\n\\`\\`\\`bash\nnpx xrmforge build # IIFE bundles for D365\nnpx xrmforge build --watch # Watch mode (~10ms rebuilds)\n\\`\\`\\`\n\n## @types/xrm Pitfalls (known issues)\n\nWhen creating manual typings without \\`xrmforge generate\\`:\n\n1. **Form Interface:** Do NOT use \\`interface extends Xrm.FormContext\\` (getAttribute overload conflicts).\n Use \\`Omit\\` pattern instead:\n \\`\\`\\`typescript\n interface AccountMainForm extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {\n getAttribute(name: Fields.AccountName): Xrm.Attributes.StringAttribute;\n getAttribute(name: string): Xrm.Attributes.Attribute;\n // ...\n }\n \\`\\`\\`\n\n2. **AlertDialogResponse** does NOT exist in @types/xrm. Use \\`Xrm.Async.PromiseLike<void>\\`.\n\n3. **ConfirmDialogResponse** does NOT exist. Use \\`Xrm.Navigation.ConfirmResult\\`.\n\n4. **setNotification()** requires 2 arguments: (message, uniqueId).\n\n5. **openFile()** requires \\`fileSize\\` property in FileDetails.\n\n6. **const enum in .d.ts files** cannot be imported at runtime by test frameworks.\n For manual typings, use regular \\`enum\\` in \\`.ts\\` files (not \\`.d.ts\\`).\n\n## Full Migration Guide\n\nSee: https://www.npmjs.com/package/@xrmforge/typegen (MIGRATION.md)\n`;\n}\n\nfunction generateExampleForm(namespace: string): string {\n return `/**\n * Example Form Script for Dynamics 365.\n *\n * Register in D365 as: ${namespace}.Example.onLoad\n *\n * Replace this with your actual form logic.\n */\n\n/**\n * Called when the form loads.\n */\nexport function onLoad(executionContext: Xrm.Events.EventContext): void {\n const formContext = executionContext.getFormContext();\n\n // Example: show a notification on the form\n formContext.ui.setFormNotification(\n 'Form loaded successfully',\n 'INFO',\n 'example-notification',\n );\n\n // Example: read a field value\n const nameAttr = formContext.getAttribute('name');\n if (nameAttr) {\n const value = nameAttr.getValue();\n console.log('Name field value:', value);\n }\n}\n\n/**\n * Called when the form is saved.\n */\nexport function onSave(executionContext: Xrm.Events.EventContext): void {\n const formContext = executionContext.getFormContext();\n\n // Clear the notification on save\n formContext.ui.clearFormNotification('example-notification');\n}\n`;\n}\n\nfunction generateExampleTest(namespace: string): string {\n return `import { describe, it, expect } from 'vitest';\n\n/**\n * Example test for the form script.\n *\n * Uses @xrmforge/testing for type-safe mocking once you have\n * generated types. For now, this is a placeholder.\n */\ndescribe('${namespace}.Example', () => {\n it('should export onLoad function', async () => {\n const mod = await import('../../src/forms/example-form.js');\n expect(typeof mod.onLoad).toBe('function');\n });\n\n it('should export onSave function', async () => {\n const mod = await import('../../src/forms/example-form.js');\n expect(typeof mod.onSave).toBe('function');\n });\n});\n`;\n}\n\nfunction generateGitHubActionsCI(): string {\n return `name: CI\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\njobs:\n build:\n runs-on: ubuntu-latest\n\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: 20\n\n - run: npm ci\n\n - name: Generate types from Dataverse\n run: npx xrmforge generate --from-config\n env:\n XRMFORGE_CLIENT_ID: \\${{ secrets.XRMFORGE_CLIENT_ID }}\n XRMFORGE_CLIENT_SECRET: \\${{ secrets.XRMFORGE_CLIENT_SECRET }}\n XRMFORGE_TENANT_ID: \\${{ secrets.XRMFORGE_TENANT_ID }}\n\n - name: Type check\n run: npx tsc --noEmit\n\n - name: Test\n run: npx vitest run\n\n - name: Build WebResources\n run: npx xrmforge build\n`;\n}\n\nfunction generateAzureDevOpsPipeline(): string {\n return `trigger:\n branches:\n include:\n - main\n\npool:\n vmImage: 'ubuntu-latest'\n\nsteps:\n - task: NodeTool@0\n inputs:\n versionSpec: '20.x'\n displayName: 'Install Node.js'\n\n - script: npm ci\n displayName: 'Install dependencies'\n\n - script: npx xrmforge generate --from-config\n displayName: 'Generate types from Dataverse'\n env:\n XRMFORGE_CLIENT_ID: \\$(XRMFORGE_CLIENT_ID)\n XRMFORGE_CLIENT_SECRET: \\$(XRMFORGE_CLIENT_SECRET)\n XRMFORGE_TENANT_ID: \\$(XRMFORGE_TENANT_ID)\n\n - script: npx tsc --noEmit\n displayName: 'Type check'\n\n - script: npx vitest run\n displayName: 'Test'\n\n - script: npx xrmforge build\n displayName: 'Build WebResources'\n`;\n}\n"],"mappings":"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","names":["BuildErrorCode","context","build","mkdir","mkdir"]}
1
+ {"version":3,"sources":["../src/errors.ts","../src/config.ts","../src/builder/esbuild-builder.ts","../src/scaffold/scaffold.ts","../src/scaffold/template-loader.ts"],"sourcesContent":["/**\n * @xrmforge/devkit - Build Error Types\n *\n * Structured error types for build operations.\n */\n\nexport enum BuildErrorCode {\n /** Build configuration is invalid or missing required fields */\n CONFIG_INVALID = 'BUILD_6001',\n /** Entry point file not found on disk */\n ENTRY_NOT_FOUND = 'BUILD_6002',\n /** esbuild compilation failed (syntax errors, missing imports) */\n BUILD_FAILED = 'BUILD_6003',\n /** Error in watch mode */\n WATCH_ERROR = 'BUILD_6004',\n}\n\n/**\n * Error class for build operations.\n * Carries a machine-readable code and optional context for debugging.\n */\nexport class BuildError extends Error {\n public readonly code: BuildErrorCode;\n public readonly context: Record<string, unknown>;\n\n constructor(code: BuildErrorCode, message: string, context: Record<string, unknown> = {}) {\n super(`[${code}] ${message}`);\n this.name = 'BuildError';\n this.code = code;\n this.context = context;\n\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, BuildError);\n }\n }\n}\n","/**\n * @xrmforge/devkit - Build Configuration\n *\n * Types and validation for the `build` section in xrmforge.config.json.\n */\n\nimport { BuildError, BuildErrorCode } from './errors.js';\n\n/** A single build entry (one WebResource) */\nexport interface BuildEntry {\n /** Relative path to the TypeScript source file */\n input: string;\n /** Global namespace for D365 form event binding (e.g. \"Contoso.Account\") */\n namespace: string;\n /** Optional output filename relative to outDir (defaults to entry key + \".js\") */\n out?: string;\n}\n\n/** Build configuration for WebResource bundling */\nexport interface BuildConfig {\n /** Bundler to use (currently only \"esbuild\") */\n bundler?: 'esbuild';\n /** Named build entries: key = entry name, value = entry config */\n entries: Record<string, BuildEntry>;\n /** Output directory for built bundles (default: \"./dist\") */\n outDir?: string;\n /** JavaScript target version (default: \"es2020\") */\n target?: string;\n /** Generate source maps (default: true) */\n sourcemap?: boolean;\n /** Minify output (default: false) */\n minify?: boolean;\n /** Additional modules to exclude from bundling */\n external?: string[];\n}\n\n/** Fully resolved build config with all defaults applied */\nexport interface ResolvedBuildConfig {\n bundler: 'esbuild';\n entries: Record<string, BuildEntry>;\n outDir: string;\n target: string;\n sourcemap: boolean;\n minify: boolean;\n external: string[];\n}\n\n/**\n * Validate a raw build config object.\n * Throws BuildError with CONFIG_INVALID if any required field is missing or invalid.\n */\nexport function validateBuildConfig(raw: unknown): BuildConfig {\n if (!raw || typeof raw !== 'object') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration must be an object.',\n );\n }\n\n const config = raw as Record<string, unknown>;\n\n // entries: required, non-empty object\n if (!config['entries'] || typeof config['entries'] !== 'object' || Array.isArray(config['entries'])) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration requires an \"entries\" object with at least one entry.',\n );\n }\n\n const entries = config['entries'] as Record<string, unknown>;\n const entryNames = Object.keys(entries);\n\n if (entryNames.length === 0) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n 'Build configuration requires at least one entry in \"entries\".',\n );\n }\n\n for (const name of entryNames) {\n const entry = entries[name] as Record<string, unknown> | undefined;\n\n if (!entry || typeof entry !== 'object') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" must be an object with \"input\" and \"namespace\".`,\n { entry: name },\n );\n }\n\n if (!entry['input'] || typeof entry['input'] !== 'string') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" requires an \"input\" field (path to .ts source file).`,\n { entry: name },\n );\n }\n\n if (!entry['namespace'] || typeof entry['namespace'] !== 'string') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Entry \"${name}\" requires a \"namespace\" field (e.g. \"Contoso.Account\").`,\n { entry: name },\n );\n }\n }\n\n // bundler: optional, must be \"esbuild\" if set\n if (config['bundler'] !== undefined && config['bundler'] !== 'esbuild') {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Unsupported bundler: \"${String(config['bundler'])}\". Currently only \"esbuild\" is supported.`,\n { bundler: config['bundler'] },\n );\n }\n\n return config as unknown as BuildConfig;\n}\n\n/**\n * Apply default values to a validated build config.\n */\nexport function resolveBuildConfig(config: BuildConfig): ResolvedBuildConfig {\n return {\n bundler: config.bundler ?? 'esbuild',\n entries: config.entries,\n outDir: config.outDir ?? './dist',\n target: config.target ?? 'es2020',\n sourcemap: config.sourcemap ?? true,\n minify: config.minify ?? false,\n external: config.external ?? [],\n };\n}\n","/**\n * @xrmforge/devkit - esbuild Builder\n *\n * Builds D365 WebResources as IIFE bundles with named globals.\n * Abstracts esbuild so users never write esbuild config.\n */\n\nimport * as esbuild from 'esbuild';\nimport { stat, mkdir } from 'node:fs/promises';\nimport { resolve, dirname } from 'node:path';\nimport type { BuildConfig } from '../config.js';\nimport { resolveBuildConfig } from '../config.js';\nimport { BuildError, BuildErrorCode } from '../errors.js';\nimport type { BuildResult, BuildResultEntry } from './types.js';\n\n/**\n * Build all entries defined in the config as IIFE bundles.\n *\n * @param config - Validated build configuration\n * @param cwd - Working directory for resolving relative paths (defaults to process.cwd())\n * @returns Build result with per-entry details\n */\nexport async function build(config: BuildConfig, cwd?: string): Promise<BuildResult> {\n const startTime = Date.now();\n const resolved = resolveBuildConfig(config);\n const basedir = cwd ?? process.cwd();\n const outDir = resolve(basedir, resolved.outDir);\n\n // Ensure output directory exists\n await mkdir(outDir, { recursive: true });\n\n const entryNames = Object.keys(resolved.entries);\n const results: BuildResultEntry[] = [];\n const errors: string[] = [];\n const warnings: string[] = [];\n\n // Build all entries in parallel\n const settled = await Promise.allSettled(\n entryNames.map(async (name) => {\n const entry = resolved.entries[name]!;\n const entryStart = Date.now();\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\n\n // Ensure subdirectory exists for custom out paths\n await mkdir(dirname(outFile), { recursive: true });\n\n const buildOptions: esbuild.BuildOptions = {\n entryPoints: [resolve(basedir, entry.input)],\n bundle: true,\n format: 'iife',\n globalName: entry.namespace,\n outfile: outFile,\n target: [resolved.target],\n minify: resolved.minify,\n sourcemap: resolved.sourcemap,\n treeShaking: true,\n logLevel: 'silent',\n external: resolved.external,\n };\n\n const result = await esbuild.build(buildOptions);\n\n // Collect esbuild warnings\n for (const w of result.warnings) {\n warnings.push(`[${name}] ${w.text}`);\n }\n\n // Get output file size\n const stats = await stat(outFile);\n\n return {\n name,\n outFile,\n sizeBytes: stats.size,\n durationMs: Date.now() - entryStart,\n } satisfies BuildResultEntry;\n }),\n );\n\n for (let i = 0; i < settled.length; i++) {\n const outcome = settled[i]!;\n const name = entryNames[i]!;\n\n if (outcome.status === 'fulfilled') {\n results.push(outcome.value);\n } else {\n const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);\n // Distinguish \"file not found\" from other build errors\n if (errorMsg.includes('Could not resolve') || errorMsg.includes('ENOENT')) {\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.ENTRY_NOT_FOUND, errorMsg, { entry: name }).message}`);\n } else {\n errors.push(`[${name}] ${new BuildError(BuildErrorCode.BUILD_FAILED, errorMsg, { entry: name }).message}`);\n }\n }\n }\n\n return {\n entries: results,\n totalDurationMs: Date.now() - startTime,\n errors,\n warnings,\n };\n}\n\n/**\n * Start watch mode for all entries.\n * Returns a dispose function to stop watching.\n *\n * @param config - Validated build configuration\n * @param options - Watch options\n * @returns Object with dispose() to stop watching\n */\nexport async function watch(\n config: BuildConfig,\n options?: {\n cwd?: string;\n onRebuild?: (result: BuildResult) => void;\n },\n): Promise<{ dispose: () => Promise<void> }> {\n const resolved = resolveBuildConfig(config);\n const basedir = options?.cwd ?? process.cwd();\n const outDir = resolve(basedir, resolved.outDir);\n\n await mkdir(outDir, { recursive: true });\n\n const contexts: esbuild.BuildContext[] = [];\n\n for (const [name, entry] of Object.entries(resolved.entries)) {\n const outFile = resolve(outDir, entry.out ?? `${name}.js`);\n await mkdir(dirname(outFile), { recursive: true });\n\n const ctx = await esbuild.context({\n entryPoints: [resolve(basedir, entry.input)],\n bundle: true,\n format: 'iife',\n globalName: entry.namespace,\n outfile: outFile,\n target: [resolved.target],\n minify: resolved.minify,\n sourcemap: resolved.sourcemap,\n treeShaking: true,\n logLevel: 'silent',\n external: resolved.external,\n });\n\n contexts.push(ctx);\n await ctx.watch();\n }\n\n return {\n dispose: async () => {\n for (const ctx of contexts) {\n await ctx.dispose();\n }\n },\n };\n}\n","/**\n * @xrmforge/devkit - Project Scaffolding\n *\n * Generates a complete D365 form scripting project from templates.\n */\n\nimport { mkdir, writeFile, readdir, access } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { ScaffoldConfig, ScaffoldResult } from './types.js';\nimport { BuildError, BuildErrorCode } from '../errors.js';\nimport { loadTemplate } from './template-loader.js';\n\n/**\n * Scaffold a new D365 form scripting project.\n *\n * Creates a complete project structure with package.json, tsconfig,\n * xrmforge.config.json, example form script, and test file.\n *\n * @param config - Scaffold configuration\n * @returns List of created files and any warnings\n * @throws {BuildError} if target directory is not empty (unless files are only dotfiles)\n */\nexport async function scaffoldProject(config: ScaffoldConfig): Promise<ScaffoldResult> {\n const { targetDir } = config;\n const filesCreated: string[] = [];\n const warnings: string[] = [];\n\n // Ensure target directory exists\n await mkdir(targetDir, { recursive: true });\n\n // Check if directory is empty (ignore dotfiles and node_modules)\n const existing = await readdir(targetDir);\n const nonDotFiles = existing.filter((f) => !f.startsWith('.') && f !== 'node_modules');\n if (nonDotFiles.length > 0 && !config.force) {\n throw new BuildError(\n BuildErrorCode.CONFIG_INVALID,\n `Target directory is not empty: ${targetDir}\\n` +\n `Found: ${nonDotFiles.slice(0, 5).join(', ')}${nonDotFiles.length > 5 ? '...' : ''}\\n` +\n `Use --force to scaffold anyway (existing files will be skipped).`,\n { targetDir, existingFiles: nonDotFiles },\n );\n }\n\n // Create directory structure\n const dirs = [\n 'src/forms',\n 'typings',\n 'tests/forms',\n ];\n\n for (const dir of dirs) {\n await mkdir(join(targetDir, dir), { recursive: true });\n }\n\n // Generate and write all template files\n const templates = await generateTemplates(config);\n\n for (const [relativePath, content] of templates) {\n const absolutePath = join(targetDir, relativePath);\n await mkdir(join(absolutePath, '..'), { recursive: true });\n\n // In force mode: skip files that already exist\n if (config.force) {\n try {\n await access(absolutePath);\n warnings.push(`Skipped ${relativePath} (already exists)`);\n continue;\n } catch {\n // File doesn't exist, proceed with write\n }\n }\n\n await writeFile(absolutePath, content, 'utf-8');\n filesCreated.push(relativePath);\n }\n\n return { filesCreated, warnings };\n}\n\n/**\n * Generate all template file contents.\n * Returns an array of [relativePath, content] tuples.\n */\nasync function generateTemplates(config: ScaffoldConfig): Promise<Array<[string, string]>> {\n const { projectName, prefix, namespace } = config;\n const lowerPrefix = prefix.toLowerCase();\n const namespaceVars = { namespace };\n\n return [\n ['package.json', generatePackageJson(projectName)],\n ['tsconfig.json', generateTsConfig()],\n ['xrmforge.config.json', generateXrmForgeConfig(lowerPrefix, namespace)],\n ['vitest.config.ts', await loadTemplate('vitest.config.ts')],\n ['.gitignore', await loadTemplate('gitignore')],\n ['AGENT.md', await loadTemplate('AGENT.md')],\n ['src/forms/example-form.ts', await loadTemplate('example-form.ts', namespaceVars)],\n ['typings/.gitkeep', ''],\n ['tests/forms/example-form.test.ts', await loadTemplate('example-form.test.ts', namespaceVars)],\n ['.github/workflows/ci.yml', await loadTemplate('github-actions-ci.yml')],\n ['azure-pipelines.yml', await loadTemplate('azure-pipelines.yml')],\n ];\n}\n\nfunction generatePackageJson(projectName: string): string {\n const pkg = {\n name: projectName,\n version: '0.1.0',\n private: true,\n type: 'module',\n scripts: {\n generate: 'xrmforge generate',\n typecheck: 'tsc --noEmit',\n build: 'xrmforge build',\n watch: 'xrmforge build --watch',\n test: 'vitest run',\n 'test:watch': 'vitest',\n },\n devDependencies: {\n '@types/xrm': '^9.0.90',\n '@xrmforge/cli': '^0.4.3',\n '@xrmforge/testing': '^0.2.0',\n '@xrmforge/helpers': '^0.1.0',\n typescript: '^5.7.0',\n vitest: '^3.0.0',\n },\n };\n return JSON.stringify(pkg, null, 2) + '\\n';\n}\n\nfunction generateTsConfig(): string {\n const config = {\n compilerOptions: {\n target: 'ES2020',\n module: 'ESNext',\n moduleResolution: 'bundler',\n lib: ['ES2020', 'DOM'],\n types: ['xrm'],\n strict: true,\n noEmit: true,\n skipLibCheck: false,\n esModuleInterop: true,\n },\n include: [\n 'src/**/*.ts',\n 'typings/**/*.d.ts',\n 'typings/**/*.ts',\n ],\n };\n return JSON.stringify(config, null, 2) + '\\n';\n}\n\nfunction generateXrmForgeConfig(prefix: string, namespace: string): string {\n const config = {\n build: {\n outDir: `./dist/${prefix}_/JS`,\n target: 'es2020',\n sourcemap: true,\n minify: true,\n entries: {\n example_form: {\n input: './src/forms/example-form.ts',\n namespace: `${namespace}.Example`,\n out: 'Example/OnLoad.js',\n },\n },\n },\n };\n return JSON.stringify(config, null, 2) + '\\n';\n}\n","/**\n * Template loader for scaffold templates.\n *\n * Reads template files from the templates/ directory relative to this module.\n * Supports {{placeholder}} variable substitution.\n */\n\nimport { readFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\n/** Path to the templates directory (relative to compiled output or source). */\nconst TEMPLATES_DIR = join(__dirname, 'templates');\n\n/**\n * Load a template file by name and optionally substitute variables.\n *\n * Variables in the template use the `{{key}}` syntax.\n *\n * @param name - Template filename (e.g. 'AGENT.md', 'example-form.ts')\n * @param vars - Optional key-value pairs for placeholder substitution\n * @returns Template content with variables replaced\n */\nexport async function loadTemplate(\n name: string,\n vars?: Record<string, string>,\n): Promise<string> {\n const content = await readFile(join(TEMPLATES_DIR, name), 'utf-8');\n\n if (!vars || Object.keys(vars).length === 0) {\n return content;\n }\n\n return Object.entries(vars).reduce(\n (result, [key, value]) => result.replaceAll(`{{${key}}}`, value),\n content,\n );\n}\n"],"mappings":"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","names":["BuildErrorCode","context","build","mkdir","join","dirname","mkdir","join"]}
@@ -0,0 +1,189 @@
1
+ # XrmForge - AI Agent Instructions
2
+
3
+ This file helps AI coding assistants write optimal Dynamics 365 form scripts.
4
+
5
+ ## Packages
6
+
7
+ - `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata
8
+ - `@xrmforge/helpers` - Browser-safe runtime: select(), parseLookup(), typedForm(), Xrm constants, Action executors
9
+ - `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange()
10
+ - `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
11
+ - `@xrmforge/eslint-plugin` - D365-specific ESLint rules
12
+
13
+ ## Generated Types (generated/ directory)
14
+
15
+ Run `xrmforge generate` to create:
16
+ - `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
17
+ - `generated/optionsets/{entity}.ts` - OptionSet const enums
18
+ - `generated/entities/{entity}.ts` - Entity interface
19
+ - `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
20
+ - `generated/entity-names.ts` - EntityNames const enum
21
+ - `generated/index.ts` - Barrel file with `export * from` re-exports
22
+
23
+ ## Rules: Always
24
+
25
+ 1. **Fields Enum** for getAttribute/getControl (not raw strings):
26
+ `form.getAttribute(Fields.AccountName)` not `form.getAttribute("name")`
27
+
28
+ 2. **OptionSet Enum** for comparisons (not magic numbers):
29
+ `status === StatusCode.Active` not `status === 0`
30
+
31
+ 3. **Cast formContext** to generated form interface:
32
+ `const form = ctx.getFormContext() as AccountMainForm;`
33
+
34
+ 4. **EntityNames Enum** for Web API calls:
35
+ `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)`
36
+
37
+ 5. **parseLookup()** from @xrmforge/helpers for lookup values
38
+
39
+ 6. **select()** from @xrmforge/helpers for $select queries
40
+
41
+ 7. **createFormMock()** from @xrmforge/testing for tests
42
+
43
+ 8. **Module exports** (not window/global assignments). esbuild globalName handles namespacing.
44
+
45
+ 9. **Tabs/Sections/Subgrids Enums** for UI access
46
+
47
+ 10. **Error handling** in all async event handlers (try/catch)
48
+
49
+ ## Rules: Never
50
+
51
+ - Never `getAttribute("raw_string")` when Fields enum exists
52
+ - Never magic numbers for OptionSet values
53
+ - Never `Xrm.Page` (deprecated since D365 v9.0)
54
+ - Never synchronous XMLHttpRequest
55
+ - Never `eval()`
56
+ - Never `window.X = ...` (use module exports)
57
+
58
+ ## Before/After Examples
59
+
60
+ ### Field Access
61
+ ```typescript
62
+ // BEFORE: formContext.getAttribute("name").getValue()
63
+ // AFTER:
64
+ import { AccountMainFormFieldsEnum as Fields } from '../generated/forms/account.js';
65
+ import type { AccountMainForm } from '../generated/forms/account.js';
66
+ const form = ctx.getFormContext() as AccountMainForm;
67
+ form.getAttribute(Fields.AccountName).getValue(); // StringAttribute, typed
68
+ ```
69
+
70
+ ### OptionSet Comparison
71
+ ```typescript
72
+ // BEFORE: if (status.getValue() === 595300002) { ... }
73
+ // AFTER:
74
+ import { StatusCode } from '../generated/optionsets/invoice.js';
75
+ if (status.getValue() === StatusCode.Gebucht) { ... }
76
+ ```
77
+
78
+ ### Testing
79
+ ```typescript
80
+ import { createFormMock } from '@xrmforge/testing';
81
+ const mock = createFormMock<AccountMainForm>({
82
+ name: 'Test', statuscode: 0
83
+ });
84
+ onLoad(mock.executionContext);
85
+ expect(mock.formContext.getControl('revenue').getVisible()).toBe(true);
86
+ ```
87
+
88
+ ## File Structure
89
+
90
+ ```
91
+ src/forms/{entity}-form.ts - Form scripts (one per entity)
92
+ src/shared/{name}.ts - Shared utilities
93
+ generated/ - Generated types (do not edit manually)
94
+ tests/forms/{entity}.test.ts - Tests
95
+ xrmforge.config.json - Build config
96
+ ```
97
+
98
+ ## Pattern Recognition: Legacy to XrmForge
99
+
100
+ When you see these patterns in legacy code, apply the XrmForge replacement:
101
+
102
+ | Legacy Pattern | XrmForge Replacement |
103
+ |---|---|
104
+ | `getAttribute("name")` | `getAttribute(Fields.Name)` |
105
+ | `getControl("name")` | `getControl(Fields.Name)` |
106
+ | `getValue() === 595300000` | `getValue() === OptionSets.StatusCode.Active` |
107
+ | `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
108
+ | `"?$select=name,revenue"` | `select(Fields.Name, Fields.Revenue)` (from @xrmforge/helpers) |
109
+ | `value[0].id.replace("{","")...` | `parseLookup(form.getAttribute(Fields.X))` (from @xrmforge/helpers) |
110
+ | `Xrm.Page.getAttribute(...)` | `formContext.getAttribute(...)` |
111
+ | `var formContext` (global) | `const form = ctx.getFormContext()` (parameter) |
112
+ | `function form_OnLoad(ctx)` | `export function onLoad(ctx: Xrm.Events.EventContext)` |
113
+ | `.then(success, error)` | `async/await with try/catch` |
114
+
115
+ ### Creating OptionSet Enums from Legacy Magic Numbers
116
+
117
+ When you find magic numbers like `getValue() === 105710002` in legacy code:
118
+ 1. Search the file for ALL numeric comparisons with getValue()
119
+ 2. Create a const enum in generated/optionsets/ with descriptive names
120
+ 3. Import and use the enum instead of the number
121
+
122
+ Example:
123
+ ```typescript
124
+ // generated/optionsets/invoice.ts
125
+ export const enum InvoiceStatusCode {
126
+ Neu = 1,
127
+ Versendet = 105710000,
128
+ Abgeschlossen = 105710001,
129
+ Gebucht = 105710002,
130
+ }
131
+
132
+ // In the form script:
133
+ import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
134
+ if (status.getValue() === InvoiceStatusCode.Gebucht) { ... }
135
+ ```
136
+
137
+ ## Testing with Global Xrm Mock
138
+
139
+ Use `setupXrmMock()` from @xrmforge/testing to mock the global Xrm namespace:
140
+ ```typescript
141
+ import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
142
+
143
+ beforeEach(() => setupXrmMock());
144
+ afterEach(() => teardownXrmMock());
145
+
146
+ // Override specific WebApi methods:
147
+ setupXrmMock({
148
+ webApiOverrides: {
149
+ retrieveMultipleRecords: async () => ({ entities: [{ name: 'Test' }] }),
150
+ },
151
+ });
152
+ ```
153
+
154
+ ## Build
155
+
156
+ ```bash
157
+ npx xrmforge build # IIFE bundles for D365
158
+ npx xrmforge build --watch # Watch mode (~10ms rebuilds)
159
+ ```
160
+
161
+ ## @types/xrm Pitfalls (known issues)
162
+
163
+ When creating manual typings without `xrmforge generate`:
164
+
165
+ 1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext` (getAttribute overload conflicts).
166
+ Use `Omit` pattern instead:
167
+ ```typescript
168
+ interface AccountMainForm extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {
169
+ getAttribute(name: Fields.AccountName): Xrm.Attributes.StringAttribute;
170
+ getAttribute(name: string): Xrm.Attributes.Attribute;
171
+ // ...
172
+ }
173
+ ```
174
+
175
+ 2. **AlertDialogResponse** does NOT exist in @types/xrm. Use `Xrm.Async.PromiseLike<void>`.
176
+
177
+ 3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
178
+
179
+ 4. **setNotification()** requires 2 arguments: (message, uniqueId).
180
+
181
+ 5. **openFile()** requires `fileSize` property in FileDetails.
182
+
183
+ 6. **const enum in .d.ts files** cannot be imported at runtime by test frameworks.
184
+ Since v0.8.0, XrmForge generates `.ts` files, so this is no longer an issue.
185
+ For manual typings, use regular `enum` in `.ts` files (not `.d.ts`).
186
+
187
+ ## Full Migration Guide
188
+
189
+ See: https://www.npmjs.com/package/@xrmforge/typegen (MIGRATION.md)
@@ -0,0 +1,32 @@
1
+ trigger:
2
+ branches:
3
+ include:
4
+ - main
5
+
6
+ pool:
7
+ vmImage: 'ubuntu-latest'
8
+
9
+ steps:
10
+ - task: NodeTool@0
11
+ inputs:
12
+ versionSpec: '20.x'
13
+ displayName: 'Install Node.js'
14
+
15
+ - script: npm ci
16
+ displayName: 'Install dependencies'
17
+
18
+ - script: npx xrmforge generate --from-config
19
+ displayName: 'Generate types from Dataverse'
20
+ env:
21
+ XRMFORGE_CLIENT_ID: $(XRMFORGE_CLIENT_ID)
22
+ XRMFORGE_CLIENT_SECRET: $(XRMFORGE_CLIENT_SECRET)
23
+ XRMFORGE_TENANT_ID: $(XRMFORGE_TENANT_ID)
24
+
25
+ - script: npx tsc --noEmit
26
+ displayName: 'Type check'
27
+
28
+ - script: npx vitest run
29
+ displayName: 'Test'
30
+
31
+ - script: npx xrmforge build
32
+ displayName: 'Build WebResources'
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ /**
4
+ * Example test for the form script.
5
+ *
6
+ * Uses @xrmforge/testing for type-safe mocking once you have
7
+ * generated types. For now, this is a placeholder.
8
+ */
9
+ describe('{{namespace}}.Example', () => {
10
+ it('should export onLoad function', async () => {
11
+ const mod = await import('../../src/forms/example-form.js');
12
+ expect(typeof mod.onLoad).toBe('function');
13
+ });
14
+
15
+ it('should export onSave function', async () => {
16
+ const mod = await import('../../src/forms/example-form.js');
17
+ expect(typeof mod.onSave).toBe('function');
18
+ });
19
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Example Form Script for Dynamics 365.
3
+ *
4
+ * Register in D365 as: {{namespace}}.Example.onLoad
5
+ *
6
+ * Replace this with your actual form logic.
7
+ */
8
+
9
+ /**
10
+ * Called when the form loads.
11
+ */
12
+ export function onLoad(executionContext: Xrm.Events.EventContext): void {
13
+ const formContext = executionContext.getFormContext();
14
+
15
+ // Example: show a notification on the form
16
+ formContext.ui.setFormNotification(
17
+ 'Form loaded successfully',
18
+ 'INFO',
19
+ 'example-notification',
20
+ );
21
+
22
+ // Example: read a field value
23
+ // TODO: Replace with generated Fields enum after running 'xrmforge generate'
24
+ // Example: formContext.getAttribute(Fields.Name)
25
+ const nameAttr = formContext.getAttribute('name');
26
+ if (nameAttr) {
27
+ const value = nameAttr.getValue();
28
+ console.log('Name field value:', value);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Called when the form is saved.
34
+ */
35
+ export function onSave(executionContext: Xrm.Events.EventContext): void {
36
+ const formContext = executionContext.getFormContext();
37
+
38
+ // Clear the notification on save
39
+ formContext.ui.clearFormNotification('example-notification');
40
+ }
@@ -0,0 +1,36 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 20
19
+
20
+ - run: npm ci
21
+
22
+ - name: Generate types from Dataverse
23
+ run: npx xrmforge generate --from-config
24
+ env:
25
+ XRMFORGE_CLIENT_ID: ${{ secrets.XRMFORGE_CLIENT_ID }}
26
+ XRMFORGE_CLIENT_SECRET: ${{ secrets.XRMFORGE_CLIENT_SECRET }}
27
+ XRMFORGE_TENANT_ID: ${{ secrets.XRMFORGE_TENANT_ID }}
28
+
29
+ - name: Type check
30
+ run: npx tsc --noEmit
31
+
32
+ - name: Test
33
+ run: npx vitest run
34
+
35
+ - name: Build WebResources
36
+ run: npx xrmforge build
@@ -0,0 +1,19 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # XrmForge cache
8
+ .xrmforge/
9
+
10
+ # IDE
11
+ .vscode/settings.json
12
+ .idea/
13
+
14
+ # OS
15
+ .DS_Store
16
+ Thumbs.db
17
+
18
+ # Logs
19
+ *.log
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: false,
6
+ include: ['tests/**/*.test.ts'],
7
+ },
8
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrmforge/devkit",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Build orchestration and project tooling for Dynamics 365 WebResources",
5
5
  "keywords": [
6
6
  "dynamics-365",
@@ -46,7 +46,7 @@
46
46
  "node": ">=20.0.0"
47
47
  },
48
48
  "scripts": {
49
- "build": "tsup",
49
+ "build": "tsup && node scripts/copy-templates.mjs",
50
50
  "dev": "tsup --watch",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",