@dforge-core/dforge-mcp 0.2.44 → 0.2.45

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +1 -1
  3. package/dist/server.js +109 -84
  4. package/package.json +3 -3
  5. package/resources/docs/conventions.md +17 -14
  6. package/resources/docs/dsl-reference.md +15 -5
  7. package/resources/schemas/entity.schema.json +1 -1
  8. package/resources/schemas/registers.schema.json +8 -3
  9. package/skills/dforge-mcp-author/SKILL.md +3 -2
  10. package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
  11. package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
  12. package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
  13. package/skills/dforge-mcp-author/references/action-dsl.md +20 -7
  14. package/skills/dforge-mcp-author/references/conventions.md +7 -1
  15. package/skills/dforge-mcp-author/references/field-types.md +3 -2
  16. package/skills/dforge-mcp-author/references/formulas.md +5 -1
  17. package/skills/dforge-mcp-author/references/print-templates.md +5 -2
  18. package/skills/dforge-mcp-author/references/queries.md +1 -1
  19. package/skills/dforge-mcp-author/references/registers.md +18 -3
  20. package/skills/dforge-mcp-author/references/reports.md +4 -1
  21. package/skills/dforge-mcp-author/references/schema-import.md +1 -1
  22. package/skills/dforge-mcp-author/references/security.md +1 -1
  23. package/skills/dforge-mcp-author/references/translations.md +38 -15
  24. package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
  25. package/skills/dforge-module-build/SKILL.md +10 -1
  26. package/skills/dforge-module-ship/SKILL.md +31 -3
  27. package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +0 -35
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
+ ## 0.2.45 — 2026-10-25
8
+
9
+ **English is the base locale, and it lives in the definitions.** Install never reads
10
+ `translations/en.json` / `en-*.json`, so the English file the tools wrote was dead weight: its labels
11
+ were never applied, and the English UI kept showing whatever the definitions said. `dforge_module_create`
12
+ (preset `full`) no longer writes `translations/en-US.json`; `dforge_translation_sync` defaults to the
13
+ `supportedLocales` entries only, refuses an English locale, and warns about a leftover English file;
14
+ `dforge_module_validate` warns about one instead of requiring role labels in it; `dforge_module_plan`
15
+ defaults to no extra locales. The sync seeds a setting from its `description` and a view, menu, folder or
16
+ action from `label`, else `description` — what install shows. `references/translations.md` gains the
17
+ table of where each object's English text lives, and the column-security example drops its
18
+ `en-US.json` for short labels in the definitions.
19
+
20
+ **File map keys use `/` on Windows too.** Every tool that builds a `files` key from a path (field,
21
+ entity, view, menu, action, role-right, seed, diagram and translation tools, the refactors) returned
22
+ `translations\de-DE.json`-style keys on Windows; they now return `translations/de-DE.json` on every OS.
23
+
24
+ **DSL builtins.** `attachText(content, fileName, encoding?, mimeType?)` / `attachBase64(base64, fileName, mimeType?)` build a file for `sendEmail()`'s fourth argument, keeping its own type (an `.ics` as `text/calendar`); a scheduled job can attach them, unlike a `printPdf()` file. Follows metadata 0.0.45; `dforge_action_check` knows both. **Needs `@dforge-core/dforge-cli` with them** (0.2.36).
25
+
26
+ **Schemas follow metadata 0.0.46.** `dforge://schema/registers` accepts `importedKeyField` on an accounting register; `dforge://schema/entity` accepts the `filesize` field type.
27
+
28
+ **Registers and actions docs.** `getAccountBalance` / `getAccountTurnover` take `exclude` as one document or an array, and an accounting register's `importedKeyField` leaves out postings loaded beside a document. An action in `ui/actions.json` can ship `paramSets` (saved parameter values; a `=` formula or a template reads the record) and a `previewTemplate` (a PDF beside the run dialog's params). **Needs `@dforge-core/dforge-cli` 0.2.36.**
29
+
30
+ **DSL docs.** `[owner_id].[email]` / `[owner_id].[full_name]` read the user behind a `user` column (platform #1499); `dforge://reference/dsl` says so.
31
+
32
+ **Registers reference.** Which kind folds rows per dimension tuple (only `balance`); the account-read
33
+ `options` in the reads table; the balance at a document's moment within one second (`before` plus
34
+ `exclude`, the caller ordering what the platform does not); `importedKeyField` marked as being for a
35
+ history loaded from another system; how long a cache rebuild at install can take. Common mistakes gain an
36
+ empty dimension (no `allowEmpty` on any kind; `=COALESCE(...)` or `skipPostingWhenEmpty`), `headerMap`
37
+ taking columns only and why each must be locked, one constant for two resources, a month missing from the
38
+ period calendar counting as closed (`DF003`), and the `registers/column-not-numeric` warning. The engine
39
+ writes an analytic slot's `entity` as `module.entity` (platform #1544), and a loader writes the same; a
40
+ filter matches either spelling, `groupBy` splits old bare slots from new ones until a migration and
41
+ `rebuild-totals`.
42
+
43
+ **DSL entity codes.** A data built-in's `module.entity` is looked up by module code, hyphen included, and
44
+ otherwise as a schema (platform #1543), so `insert('cdy-tax.x', …)` and `insert('cdy_tax.x', …)` both
45
+ work. The conventions doc notes that install refuses a schema or code another module already holds.
46
+ Roles and settings are translated (the conventions doc called roles ignored); a print template's name is
47
+ its `description`.
48
+
49
+ **DSL, formulas, fields.** An inline action stops after 10 seconds; an assignment inside a one-line block
50
+ fails; indexing after a call or an index compiles since platform #1537. `TODAY()` / `NOW()` follow the
51
+ request's time zone, a `datetime` column may pin `params.timeZone`, a number-literal formula becomes a
52
+ column `DEFAULT`, and a bare `$[setting]` formula fills a column an insert leaves out. A zero divisor
53
+ fails wherever a formula runs in SQL.
54
+
55
+ **Reports and print.** A stored procedure's result-column `baseDatatypeCd` codes (`timestamp`, not
56
+ `datetime`); `ROWS 1` on a one-row table function, since install's `SET search_path` stops inlining; every
57
+ `logic/reports/*.sql` runs at every install (and "must be owner of function"); casting columns for
58
+ `RETURN QUERY`. Print: `%B` prints English names, `format_date` returns a string, timestamps follow the
59
+ column's or the request's zone, Scriban's `blank` on a date, stray spaces from an empty reference hop.
60
+
61
+ **Seed, demo and ship.** Seed rows are re-applied on every install and ship in the package (fictional
62
+ data only); `demo-data/` inserts but does not post, so an accounting demo runs the module's actions; a
63
+ same-version install is skipped yet reports success. `dforge-module-ship` covers an install blocked by an
64
+ open transaction (`pg_stat_activity`), a client timeout during a cache rebuild, and the local dev stack
65
+ (restart `dforge-api`, the `<tenant>._.localhost` URL, an expired CLI token, a platform binary behind the
66
+ tenant). Option labels: plain English in the definition, translated as strings.
67
+
7
68
  ## 0.2.44 — 2026-10-21
8
69
 
9
70
  **Schemas follow metadata 0.0.44.** `dforge://schema/translations` describes `storedProcedures`
package/README.md CHANGED
@@ -143,7 +143,7 @@ The field/entity tools validate against the `@dforge-core/metadata` registry: an
143
143
  | Tool | Behavior |
144
144
  |---|---|
145
145
  | `dforge_setting_add` | Configurable module-level setting |
146
- | `dforge_translation_sync` | Generate/refresh `translations/<locale>.json` from the module's own contents. Never overwrites existing translated text; fills the role labels that are **completeness-enforced at install**. Defaults to en-US + every `supportedLocales` entry |
146
+ | `dforge_translation_sync` | Generate/refresh `translations/<locale>.json` from the module's own contents. Never overwrites existing translated text; fills the role labels that are **completeness-enforced at install**. Defaults to every `supportedLocales` entry; writes no English file (install skips `en*.json` — English lives in the definitions) |
147
147
  | `dforge_seed_add` | Write a seed file for one entity. Enforces numeric `{entity}_id` PKs, parent-before-child load order, `audit-full` System-user columns, and FKs that point at seeded parents |
148
148
 
149
149
  **Security (Phase 5)**
package/dist/server.js CHANGED
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
31002
31002
  var fs2 = __toESM(require("fs"));
31003
31003
  var path2 = __toESM(require("path"));
31004
31004
 
31005
- // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.35/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.36/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31006
31006
  function buildManifest(opts, moduleId) {
31007
31007
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
31008
31008
  const manifest = {
@@ -31132,53 +31132,6 @@ function buildActions() {
31132
31132
  function buildSettings() {
31133
31133
  return {};
31134
31134
  }
31135
- function buildTranslations(opts) {
31136
- const entities = {};
31137
- for (const e of opts.entities) {
31138
- const fields = {
31139
- // identity trait → primary key column
31140
- [`${e.name}_id`]: { label: `${e.label} ID` },
31141
- [NAME_FIELD]: { label: "Name" }
31142
- };
31143
- if (e.traits === "identity+audit") {
31144
- fields.created_date = { label: "Created Date" };
31145
- fields.last_updated = { label: "Last Updated" };
31146
- }
31147
- const entityEntry = { label: e.label };
31148
- if (opts.description && opts.entities.length === 1) {
31149
- entityEntry.desc = opts.description;
31150
- }
31151
- entityEntry.fields = fields;
31152
- if (e.constraints?.length) {
31153
- entityEntry.constraints = Object.fromEntries(
31154
- e.constraints.map((c) => [c.name, { message: c.message }])
31155
- );
31156
- }
31157
- entities[e.name] = entityEntry;
31158
- }
31159
- return {
31160
- entities,
31161
- folders: {
31162
- // folders.json root folder key is the module code (see buildFolders).
31163
- [opts.code]: { label: opts.displayName }
31164
- },
31165
- roles: {
31166
- // Role labels are completeness-enforced in every locale incl. en-US.
31167
- [`${opts.code}.admin`]: { label: `${opts.displayName} Administrator` }
31168
- },
31169
- views: Object.fromEntries(
31170
- opts.entities.map((e) => [e.name, { label: e.label }])
31171
- ),
31172
- menus: {
31173
- [`${opts.code}_menu`]: {
31174
- label: opts.displayName,
31175
- items: Object.fromEntries(
31176
- opts.entities.map((e) => [e.name, { label: e.label }])
31177
- )
31178
- }
31179
- }
31180
- };
31181
- }
31182
31135
  function buildSeedData(entity) {
31183
31136
  const entityCode = typeof entity === "string" ? entity : entity?.name ?? "";
31184
31137
  return { entityCode, records: [] };
@@ -31290,7 +31243,7 @@ function buildZedTasks() {
31290
31243
  var fs = __toESM(require("fs"));
31291
31244
  var path = __toESM(require("path"));
31292
31245
 
31293
- // node_modules/.pnpm/@dforge-core+metadata@0.0.44/node_modules/@dforge-core/metadata/dist/index.js
31246
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.46/node_modules/@dforge-core/metadata/dist/index.js
31294
31247
  var AggType = {
31295
31248
  Sum: "sum",
31296
31249
  Count: "count",
@@ -31310,6 +31263,7 @@ var fieldTypes = [
31310
31263
  { cd: "number", name: "Number", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: null, useStringInFilter: false },
31311
31264
  { cd: "currency", name: "Currency", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: 2, useStringInFilter: false },
31312
31265
  { cd: "percent", name: "Percent", columnType: "D", baseDatatypeCd: "number", supportedAgg: "CAM", maxLen: null, precision: 2, useStringInFilter: false },
31266
+ { cd: "filesize", name: "File Size", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: null, useStringInFilter: false },
31313
31267
  { cd: "checkbox", name: "Checkbox", columnType: "D", baseDatatypeCd: "bool", supportedAgg: "C", maxLen: null, precision: null, useStringInFilter: false },
31314
31268
  { cd: "date", name: "Date", columnType: "D", baseDatatypeCd: "date", supportedAgg: "CM", maxLen: null, precision: null, useStringInFilter: false },
31315
31269
  { cd: "datetime", name: "Date & Time", columnType: "D", baseDatatypeCd: "timestamp", supportedAgg: "CM", maxLen: null, precision: null, useStringInFilter: false },
@@ -31509,7 +31463,8 @@ var NUMERIC_TOTAL = 18;
31509
31463
  var TEXT_BACKED = /* @__PURE__ */ new Set(["textarea", "markdown", "richtext", "code"]);
31510
31464
  var DB_DATATYPE_OVERRIDE = {
31511
31465
  file: "jsonb",
31512
- image: "jsonb"
31466
+ image: "jsonb",
31467
+ filesize: "int8"
31513
31468
  };
31514
31469
  function deriveDbDatatype(fieldTypeCd, options = {}) {
31515
31470
  const ft = getFieldType(fieldTypeCd);
@@ -31541,7 +31496,8 @@ var ACCOUNTING_PROPERTIES = [
31541
31496
  "creditAccount",
31542
31497
  "debitAnalyticFields",
31543
31498
  "creditAnalyticFields",
31544
- "sideDimensions"
31499
+ "sideDimensions",
31500
+ "importedKeyField"
31545
31501
  ];
31546
31502
  var BALANCE_PROPERTIES = ["allowNegative", "autoCreate"];
31547
31503
  var REFUSED = {
@@ -31550,6 +31506,40 @@ var REFUSED = {
31550
31506
  info: ["journal", "journalDirection", "journalDate", ...ACCOUNTING_PROPERTIES, "totals", ...BALANCE_PROPERTIES],
31551
31507
  accounting: ["entity", ...INFO_PROPERTIES, "journalDirection", ...BALANCE_PROPERTIES]
31552
31508
  };
31509
+ var SUMMED = ["balance", "turnover", "accounting"];
31510
+ var slot = (path21, on, what, summed = false) => ({
31511
+ path: path21.split("."),
31512
+ on,
31513
+ what,
31514
+ ...summed ? { numericFor: SUMMED } : {}
31515
+ });
31516
+ var REGISTER_COLUMN_SLOTS = [
31517
+ slot("journalDirection", "journal", "Journal direction column"),
31518
+ slot("journalDate", "journal", "Journal date column"),
31519
+ slot("period", "entity", "Period column"),
31520
+ slot("recorder", "entity", "Recorder column"),
31521
+ slot("dimensions.*", "carrier", "Dimension"),
31522
+ slot("resources.*.field", "carrier", "Resource '{0}' column", true),
31523
+ slot("resources.*.debitField", "carrier", "Resource '{0}' column", true),
31524
+ slot("resources.*.creditField", "carrier", "Resource '{0}' column", true),
31525
+ slot("account", "journal", "Account column"),
31526
+ slot("debitAccount", "journal", "Debit account column"),
31527
+ slot("creditAccount", "journal", "Credit account column"),
31528
+ slot("analyticFields.*", "journal", "Analytic slot column"),
31529
+ slot("debitAnalyticFields.*", "journal", "Analytic slot column"),
31530
+ slot("creditAnalyticFields.*", "journal", "Analytic slot column"),
31531
+ slot("sideDimensions.*.debitField", "journal", "Dimension '{0}' debit column"),
31532
+ slot("sideDimensions.*.creditField", "journal", "Dimension '{0}' credit column"),
31533
+ slot("composition.account", "composition", "Composition 'account' column"),
31534
+ slot("composition.slot", "composition", "Composition 'slot' column"),
31535
+ slot("composition.analytic", "composition", "Composition 'analytic' column"),
31536
+ slot("totals.periodField", "totals", "Totals period column"),
31537
+ slot("totals.accountField", "totals", "Totals account column"),
31538
+ slot("totals.resources.*.inField", "totals", "Totals '{0}' inField", true),
31539
+ slot("totals.resources.*.outField", "totals", "Totals '{0}' outField", true),
31540
+ slot("totals.resources.*.debitField", "totals", "Totals '{0}' debitField", true),
31541
+ slot("totals.resources.*.creditField", "totals", "Totals '{0}' creditField", true)
31542
+ ];
31553
31543
 
31554
31544
  // src/tools/_helpers.ts
31555
31545
  function modulePaths(moduleDir) {
@@ -31648,7 +31638,7 @@ function jsonText(obj) {
31648
31638
  return JSON.stringify(obj, null, " ") + "\n";
31649
31639
  }
31650
31640
  function rel(root, abs) {
31651
- return path.relative(root, abs);
31641
+ return path.relative(root, abs).split(path.sep).join("/");
31652
31642
  }
31653
31643
  function loadManifest(moduleDir) {
31654
31644
  const paths = modulePaths(moduleDir);
@@ -31955,7 +31945,7 @@ var createModuleSchema = {
31955
31945
  version: external_exports.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?$/).default("0.1.0"),
31956
31946
  dbSchemaVersion: external_exports.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?$/).default("0.0.1"),
31957
31947
  dependencies: external_exports.array(external_exports.string()).default([]).describe("Other modules this one requires, by code (e.g. ['parties']). Leave empty unless it genuinely depends on another module. Do NOT list 'admin' or 'metadata' \u2014 they're system modules present in every tenant, so depending on them is redundant (the check always passes and never affects install order)."),
31958
- preset: external_exports.enum(["minimal", "minimal-plus", "full"]).default("minimal").describe("'minimal' = manifest+entity+UI+role only. 'full' = also settings/translations/seed-data/logic stubs. 'minimal-plus' behaves like 'minimal' here since MCP can't loop; pass all entities up-front."),
31948
+ preset: external_exports.enum(["minimal", "minimal-plus", "full"]).default("minimal").describe("'minimal' = manifest+entity+UI+role only. 'full' = also settings/seed-data/logic stubs. No translation file: English lives in the definitions, other locales come from dforge_translation_sync. 'minimal-plus' behaves like 'minimal' here since MCP can't loop; pass all entities up-front."),
31959
31949
  entities: external_exports.array(
31960
31950
  external_exports.object({
31961
31951
  name: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).describe("Entity code, e.g. 'customer'. Lowercase, digits, underscore; first char a letter."),
@@ -32039,7 +32029,6 @@ function createModuleFiles(args) {
32039
32029
  if (buildZedTasks2) write(".zed/tasks.json", buildZedTasks2());
32040
32030
  if (opts.preset === "full") {
32041
32031
  write("settings.json", buildSettings());
32042
- write("translations/en-US.json", buildTranslations(opts));
32043
32032
  for (const e of opts.entities) {
32044
32033
  write(`seed-data/01-${e.name}.json`, buildSeedData());
32045
32034
  }
@@ -32132,9 +32121,9 @@ var planModuleSchema = {
32132
32121
  dependencies: external_exports.array(external_exports.string()).optional().describe(
32133
32122
  "[write_identity] Other dForge module codes this module depends on. admin + metadata are always implicit."
32134
32123
  ),
32135
- locales: external_exports.array(external_exports.string()).optional().describe("[write_identity] Locale codes, e.g. ['en-US', 'de-DE']. Default: ['en-US']."),
32124
+ locales: external_exports.array(external_exports.string()).optional().describe("[write_identity] Locales besides English, e.g. ['uk-UA', 'de-DE'] \u2014 recorded in CLAUDE.md only; declare them in manifest.supportedLocales and run dforge_translation_sync to create the files. English is the base: its text lives in the definitions and needs no file. Default: [] (English only)."),
32136
32125
  preset: external_exports.enum(["minimal", "minimal-plus", "full"]).optional().describe(
32137
- "[write_identity] Scaffold depth: minimal = entities + views + role; full = + settings + translations + seed-data stubs."
32126
+ "[write_identity] Scaffold depth: minimal = entities + views + role; full = + settings + seed-data stubs."
32138
32127
  ),
32139
32128
  // ── write_requirements / write_design fields (Phase 0b / 0c) ────────
32140
32129
  userConfirmed: external_exports.boolean().optional().describe(
@@ -32364,8 +32353,8 @@ function handleCheck(root) {
32364
32353
  `What's the module's display name? (e.g. "Purchase Orders", "HR Leave Requests")`,
32365
32354
  "What code should it use? (snake_case, letters + digits + underscores \u2014 becomes the DB schema name; suggest a default derived from the display name)",
32366
32355
  "Does this module depend on any other dForge modules \u2014 e.g. needing entities from 'crm' or 'parties'? (admin and metadata are platform-implicit; list any others, or say None)",
32367
- "English only, or any other locales the module needs to ship with translations for?",
32368
- "How complete should the initial scaffold be? (minimal = entities + default views + single admin role; minimal-plus = + settings/translation stubs; full = + logic stubs + seed data structure)"
32356
+ "English only, or which other locales should the module ship translations for (e.g. uk-UA)? English is the base and needs no translation file.",
32357
+ "How complete should the initial scaffold be? (minimal = entities + default views + single admin role; minimal-plus = + settings stubs; full = + logic stubs + seed data structure)"
32369
32358
  ],
32370
32359
  writeAction: "Once all answers are collected and reflected back, call dforge_module_plan({ action: 'write_identity', moduleDir, displayName, code, dependencies, locales, preset })."
32371
32360
  };
@@ -32439,11 +32428,12 @@ function handleCheck(root) {
32439
32428
  };
32440
32429
  }
32441
32430
  function handleWriteIdentity(root, args) {
32442
- const { displayName, code: code2, dependencies = [], locales = ["en-US"], preset = "minimal" } = args;
32431
+ const { displayName, code: code2, dependencies = [], preset = "minimal" } = args;
32432
+ const locales = (args.locales ?? []).filter((l) => !/^en(-|$)/i.test(l.trim()));
32443
32433
  if (!displayName) throw new Error("displayName is required for write_identity.");
32444
32434
  if (!code2) throw new Error("code is required for write_identity.");
32445
32435
  const depsText = dependencies.length > 0 ? dependencies.join(", ") : "None";
32446
- const localesText = locales.join(", ");
32436
+ const localesText = ["English (base, in the definitions)", ...locales].join(", ");
32447
32437
  const claudeMd = buildClaudeMd({ root, displayName, code: code2, depsText, localesText, preset });
32448
32438
  return {
32449
32439
  summary: `Phase 0a complete \u2014 CLAUDE.md prepared for module '${code2}'.`,
@@ -34622,7 +34612,7 @@ var path10 = __toESM(require("path"));
34622
34612
  var fs7 = __toESM(require("fs"));
34623
34613
  var path9 = __toESM(require("path"));
34624
34614
 
34625
- // node_modules/.pnpm/@dforge-core+metadata@0.0.44/node_modules/@dforge-core/metadata/dist/dsl/index.js
34615
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.46/node_modules/@dforge-core/metadata/dist/dsl/index.js
34626
34616
  var PUNCT3 = ["===", "!=="];
34627
34617
  var PUNCT2 = [
34628
34618
  "==",
@@ -35476,7 +35466,7 @@ var BUILTINS = [
35476
35466
  name: "sendEmail",
35477
35467
  signature: "sendEmail(to, subjectOrTemplate, dataOrBody?, attachments?)",
35478
35468
  returns: "void",
35479
- doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>\u2026</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code \u2014 so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. The optional **fourth** argument attaches files: a `printPdf()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference in the DSL). Only a `printPdf()` result can be attached, and only by the user who generated it (or a workspace admin); at most 5 files and 10 MB in all per email (`Email:MaxAttachments`, `Email:MaxAttachmentBytes`). A file the sender may not attach fails the action. Not available in a scheduled job, whose emails with attachments are not queued."
35469
+ doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>\u2026</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code \u2014 so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. The optional **fourth** argument attaches files: a `printPdf()`, `attachText()` or `attachBase64()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference in the DSL). A `printPdf()` file can be attached only by the user who generated it (or a workspace admin) and not in a scheduled job; a file built with `attachText()` / `attachBase64()` has no owner and works everywhere, a scheduled job included. At most 5 files and 10 MB in all per email (`Email:MaxAttachments`, `Email:MaxAttachmentBytes`). A file the sender may not attach fails the action."
35480
35470
  },
35481
35471
  // External calls
35482
35472
  {
@@ -35529,6 +35519,18 @@ var BUILTINS = [
35529
35519
  returns: "string",
35530
35520
  doc: "`downloadText()` for bytes: decodes `base64` and hands it to the user as `fileName` (`application/octet-stream` unless `mimeType` says otherwise). Returns the signed download URL."
35531
35521
  },
35522
+ {
35523
+ name: "attachText",
35524
+ signature: "attachText(content, fileName, encoding?, mimeType?)",
35525
+ returns: "object",
35526
+ doc: "Build a text file to attach to an email: encodes `content` in `encoding` (`utf-8` by default) and returns a descriptor for `sendEmail()`'s fourth argument, e.g. `sendEmail(to, 'Interview', body, attachText(ics, 'invite.ics', 'utf-8', 'text/calendar'))`. The file keeps `mimeType` (`text/plain` by default). It is the script's own output, so no user's rights are involved and it works in a scheduled job. Only the emails of this run can name it. Max 10 MB per file and 10 files per run, shared with `downloadText()` / `downloadBase64()`."
35527
+ },
35528
+ {
35529
+ name: "attachBase64",
35530
+ signature: "attachBase64(base64, fileName, mimeType?)",
35531
+ returns: "object",
35532
+ doc: "`attachText()` for bytes given as base64 (`application/octet-stream` unless `mimeType` says otherwise)."
35533
+ },
35532
35534
  {
35533
35535
  name: "getSetting",
35534
35536
  signature: "getSetting(settingCd)",
@@ -36389,10 +36391,17 @@ function checkConstraintTranslations(ctx) {
36389
36391
  function checkTranslationCompleteness(ctx) {
36390
36392
  const { paths, err, warn, roles, supportedLocales } = ctx;
36391
36393
  const roleCodes = Object.keys(roles);
36392
- const localeFiles = fs7.existsSync(paths.translationsDir) ? fs7.readdirSync(paths.translationsDir).filter((f) => f.toLowerCase().endsWith(".json")).sort() : [];
36394
+ const allFiles = fs7.existsSync(paths.translationsDir) ? fs7.readdirSync(paths.translationsDir).filter((f) => f.toLowerCase().endsWith(".json")).sort() : [];
36395
+ const localeFiles = allFiles.filter((f) => !isEnglishLocale(f.slice(0, -5)));
36396
+ for (const file2 of allFiles.filter((f) => isEnglishLocale(f.slice(0, -5)))) {
36397
+ warn(
36398
+ `translations/${file2}`,
36399
+ "install never reads an English translation file \u2014 the English UI shows the definitions' own text (an entity's or field's `description`, a menu's, action's or report's `label`, a role's or setting's `description`). Move any wording you want from this file into the definitions, then delete it."
36400
+ );
36401
+ }
36393
36402
  for (const raw of supportedLocales) {
36394
36403
  const locale = raw.trim();
36395
- if (!locale) continue;
36404
+ if (!locale || isEnglishLocale(locale)) continue;
36396
36405
  if (!resolveTranslationFile(paths.translationsDir, locale)) {
36397
36406
  err(
36398
36407
  "translations",
@@ -36400,12 +36409,6 @@ function checkTranslationCompleteness(ctx) {
36400
36409
  );
36401
36410
  }
36402
36411
  }
36403
- if (roleCodes.length > 0 && localeFiles.length === 0) {
36404
- warn(
36405
- "translations",
36406
- `no translations/ files \u2014 ship at least translations/en-US.json with a roles block (a 'label' for each of: ${roleCodes.join(", ")}); role labels are completeness-enforced at install.`
36407
- );
36408
- }
36409
36412
  for (const file2 of localeFiles) {
36410
36413
  let tx;
36411
36414
  try {
@@ -36422,7 +36425,7 @@ function checkTranslationCompleteness(ctx) {
36422
36425
  if (missing.length > 0) {
36423
36426
  err(
36424
36427
  `translations/${file2}`,
36425
- `missing roles.<code>.label for: ${missing.join(", ")} \u2014 completeness is enforced in every locale (including en-US); install fails with "Label for role '<code>'."`
36428
+ `missing roles.<code>.label for: ${missing.join(", ")} \u2014 completeness is enforced in every non-English locale file; install fails with "Label for role '<code>'."`
36426
36429
  );
36427
36430
  }
36428
36431
  }
@@ -36891,7 +36894,7 @@ function str(obj, key) {
36891
36894
  var translationSyncSchema = {
36892
36895
  moduleDir: external_exports.string().describe("Path to the module root."),
36893
36896
  locales: external_exports.array(external_exports.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/)).optional().describe(
36894
- "Locales to sync. Default: 'en-US' plus every entry in manifest.supportedLocales \u2014 i.e. exactly the set install requires."
36897
+ "Non-English locales to sync. Default: every entry in manifest.supportedLocales \u2014 exactly the set install requires. An English locale is refused: install never reads translations/en*.json, English text belongs in the definitions."
36895
36898
  ),
36896
36899
  prune: external_exports.boolean().default(false).describe(
36897
36900
  "Remove keys that no longer correspond to anything in the module (e.g. after an entity delete). Off by default \u2014 pruning discards translated text."
@@ -36907,8 +36910,8 @@ function buildSkeleton(moduleDir) {
36907
36910
  `traits.json \u2014 ${traitsError} Its traits can't be overlaid, so the entities that use one would be skipped as declaring an unknown trait and their columns would go unlabelled \u2014 which fails the install. Fix the file and sync again.`
36908
36911
  );
36909
36912
  }
36910
- const push = (section, pathParts, english, required2 = false, legacyParts, nonEnglishOnly = false) => {
36911
- entries.push({ pathParts, english, required: required2, legacyParts, nonEnglishOnly });
36913
+ const push = (section, pathParts, english, required2 = false, legacyParts) => {
36914
+ entries.push({ pathParts, english, required: required2, legacyParts });
36912
36915
  counts[section] = (counts[section] ?? 0) + 1;
36913
36916
  };
36914
36917
  for (const [name, relPath] of Object.entries(manifest.entities ?? {})) {
@@ -36990,10 +36993,10 @@ function buildSkeleton(moduleDir) {
36990
36993
  }
36991
36994
  const settings = readJsonOrDefault(paths.settings, {});
36992
36995
  for (const [code2, s] of Object.entries(settings)) {
36993
- push("settings", ["settings", code2, "label"], str(s, "label") ?? str(s, "description") ?? str(s, "desc") ?? titleize(code2));
36996
+ push("settings", ["settings", code2, "label"], str(s, "description") ?? str(s, "label") ?? str(s, "desc") ?? titleize(code2));
36994
36997
  }
36995
36998
  for (const text of actionMessageTexts(paths.logicDir, actions).keys()) {
36996
- push("messages", ["messages", text], text, false, void 0, true);
36999
+ push("messages", ["messages", text], text);
36997
37000
  }
36998
37001
  return { entries, counts };
36999
37002
  }
@@ -37022,9 +37025,20 @@ function pruneTo(existing, keep, trail = []) {
37022
37025
  function translationSync(args) {
37023
37026
  const { paths, manifest } = loadManifest(args.moduleDir);
37024
37027
  const declared = Array.isArray(manifest.supportedLocales) ? manifest.supportedLocales.filter((l) => typeof l === "string") : [];
37025
- const locales = args.locales ?? ["en-US", ...declared];
37028
+ const isEnglish = (l) => l.toLowerCase() === "en" || l.toLowerCase().startsWith("en-");
37029
+ const english = (args.locales ?? []).filter(isEnglish);
37030
+ if (english.length > 0) {
37031
+ throw new Error(
37032
+ `Refusing to sync ${english.join(", ")}: install never reads translations/en*.json. English is the base locale and lives in the definitions \u2014 an entity's or field's \`description\`, a menu's, action's or report's \`label\`, a role's or setting's \`description\`. Edit the text there.`
37033
+ );
37034
+ }
37035
+ const ignored = fs9.existsSync(paths.translationsDir) ? fs9.readdirSync(paths.translationsDir).filter((f) => f.toLowerCase().endsWith(".json") && isEnglish(f.slice(0, -5))) : [];
37036
+ const staleEnglish = ignored.length > 0 ? ` Leftover ${ignored.map((f) => `translations/${f}`).join(", ")} is never read by install \u2014 move any wording you want from it into the definitions, then delete it.` : "";
37037
+ const locales = args.locales ?? declared.filter((l) => !isEnglish(l));
37026
37038
  if (locales.length === 0) {
37027
- throw new Error("No locales to sync. Pass `locales`, or declare them in manifest.supportedLocales.");
37039
+ throw new Error(
37040
+ 'No locales to sync. An English-only module needs no translation files: its English text lives in the definitions. To translate, declare the other locales in manifest.supportedLocales (e.g. ["uk-UA"]).' + staleEnglish
37041
+ );
37028
37042
  }
37029
37043
  const { entries, counts } = buildSkeleton(args.moduleDir);
37030
37044
  if (entries.length === 0) {
@@ -37033,11 +37047,11 @@ function translationSync(args) {
37033
37047
  );
37034
37048
  }
37035
37049
  const keep = new Set(entries.map((e) => compositeKey(...e.pathParts)));
37036
- const isEnglish = (l) => l.toLowerCase() === "en" || l.toLowerCase().startsWith("en-");
37037
37050
  const files = {};
37038
37051
  const report = [];
37039
37052
  let totalAdded = 0;
37040
37053
  let missingRequired = 0;
37054
+ const seeded = [];
37041
37055
  for (const locale of locales) {
37042
37056
  const abs = path12.join(paths.translationsDir, `${locale}.json`);
37043
37057
  let existing = {};
@@ -37051,7 +37065,6 @@ function translationSync(args) {
37051
37065
  const next = args.prune ? pruneTo(existing, keep) : JSON.parse(JSON.stringify(existing));
37052
37066
  let added = 0;
37053
37067
  for (const entry of entries) {
37054
- if (entry.nonEnglishOnly && isEnglish(locale)) continue;
37055
37068
  const current = getIn(next, entry.pathParts);
37056
37069
  if (typeof current === "string" && current.trim() !== "") continue;
37057
37070
  const legacy = entry.legacyParts && getIn(existing, entry.legacyParts);
@@ -37061,10 +37074,21 @@ function translationSync(args) {
37061
37074
  }
37062
37075
  files[rel(paths.root, abs)] = jsonText(next);
37063
37076
  totalAdded += added;
37077
+ if (added > 0) seeded.push(locale);
37064
37078
  report.push(`${locale}: +${added} key(s)${added === 0 ? " (already complete)" : ""}`);
37065
37079
  }
37066
- const nonEnglish = locales.filter((l) => !isEnglish(l));
37067
- const warning = nonEnglish.length > 0 && totalAdded > 0 ? `Newly-added keys in ${nonEnglish.join(", ")} are seeded with the ENGLISH source text so the file is installable immediately \u2014 they still need real translation. (Untranslated values fall back to en-US at runtime, so this never breaks; it just shows English.)` : void 0;
37080
+ const warnings = [];
37081
+ if (totalAdded > 0) {
37082
+ warnings.push(
37083
+ `Newly-added keys in ${seeded.join(", ")} are seeded with the ENGLISH source text so the file is installable immediately \u2014 they still need real translation.`
37084
+ );
37085
+ }
37086
+ if (ignored.length > 0) {
37087
+ warnings.push(
37088
+ `${ignored.map((f) => `translations/${f}`).join(", ")} is never read by install \u2014 the English UI shows the definitions' own \`description\` / \`label\`. Move any wording you want from it into the definitions, then delete it.`
37089
+ );
37090
+ }
37091
+ const warning = warnings.length > 0 ? warnings.join(" ") : void 0;
37068
37092
  const sections = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ");
37069
37093
  return makeResult(
37070
37094
  `Synced ${locales.length} locale file(s) from ${entries.length} translatable keys (${sections}). ${report.join("; ")}.${missingRequired > 0 ? ` Filled ${missingRequired} install-blocking role label(s).` : ""}`,
@@ -38971,7 +38995,8 @@ var resources = [
38971
38995
  reference("registers", "Registers: accumulation (balance / turnover / info) and accounting (classic / compound) registers, registers.json, binding a document from its A / L columns (map, per-side debit/credit objects), and the reads. Load before authoring registers.json or an A / L column."),
38972
38996
  reference("reactions", "Field-change reactions: `reactions` on an entity (formula rules and server `call` rules) that suggest a stored, editable value when the user edits another field; a bridge module's reactions on its host's entity (`reaction:<code>` in deps); dependent references. Load before authoring any reaction."),
38973
38997
  reference("data-migration", "Migrating data from a legacy database."),
38974
- reference("manifest", "manifest.json shape: moduleId UUID, semver, entities map, locale-keyed translations, security block."),
38998
+ reference("manifest", "manifest.json shape: moduleId UUID, semver, entities map, supportedLocales (non-English only), security block."),
38999
+ reference("conventions", "Cross-cutting module rules: naming, structure, translations, upgrade migrations."),
38975
39000
  reference("validation-checklist", "Final pre-pack self-review checklist covering every file type."),
38976
39001
  // ── Canonical example module (skills/.../examples/simple-todo) ────────
38977
39002
  example("manifest.json", "Canonical manifest.json."),
@@ -39334,7 +39359,7 @@ server.registerTool(
39334
39359
  "dforge_translation_sync",
39335
39360
  {
39336
39361
  title: "Sync translation files",
39337
- description: "PHASE 4: Generate or refresh translations/<locale>.json from what the module actually contains \u2014 entity + field labels (including trait-provided columns), views, menus, roles, actions, folders, settings. Existing translated text is NEVER overwritten; missing keys are seeded with the English source so the file is immediately installable. Defaults to en-US plus every manifest.supportedLocales entry \u2014 exactly the set install requires. Run it after any entity/view/role change: role labels are completeness-enforced and a missing one FAILS install.",
39362
+ description: "PHASE 4: Generate or refresh translations/<locale>.json from what the module actually contains \u2014 entity + field labels (including trait-provided columns), views, menus, roles, actions, folders, settings. Existing translated text is NEVER overwritten; missing keys are seeded with the English source so the file is immediately installable. Defaults to every manifest.supportedLocales entry \u2014 exactly the set install requires. Writes no English file: install never reads translations/en*.json, English text lives in the definitions (entity/field/role/setting `description`, menu/action/report `label`). Run it after any entity/view/role change: role labels are completeness-enforced and a missing one FAILS install.",
39338
39363
  inputSchema: { ...translationSyncSchema, ...applyInput }
39339
39364
  },
39340
39365
  envelope(translationSync)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.44",
3
+ "version": "0.2.45",
4
4
  "description": "MCP server for dForge module authoring. Exposes 35 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
@@ -26,8 +26,8 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "dependencies": {
29
- "@dforge-core/dforge-cli": "^0.2.35",
30
- "@dforge-core/metadata": "^0.0.44",
29
+ "@dforge-core/dforge-cli": "^0.2.36",
30
+ "@dforge-core/metadata": "^0.0.46",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "zod": "^4.4.3"
33
33
  },
@@ -66,8 +66,8 @@ Use the CRM sample module (`modules/crm/`) as a reference implementation.
66
66
  ├── /security/ # Security definitions
67
67
  │ └── roles.json
68
68
  ├── /translations/ # Translations (optional)
69
- │ ├── en-US.json
70
- │ └── de-DE.json
69
+ │ ├── de-DE.json # one per supportedLocales entry — never en*.json
70
+ │ └── uk-UA.json
71
71
  ├── /print_templates/ # HTML/CSS print templates (optional)
72
72
  │ ├── invoice.html
73
73
  │ └── invoice.css
@@ -106,6 +106,7 @@ Use the CRM sample module (`modules/crm/`) as a reference implementation.
106
106
  - `entities` maps entity codes to file paths (relative to module root)
107
107
  - Omit `"system": true` for regular modules (defaults to false)
108
108
  - Omit `schemaName` to use `code` as the schema name
109
+ - The schema and the code must be free: a first install, or an upgrade that moves the module to another schema, is refused when another module already holds that schema or code (compared case-insensitively). Modules that already share a schema keep upgrading.
109
110
 
110
111
  ### 1b. Module Extensions
111
112
 
@@ -644,37 +645,39 @@ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/a
644
645
 
645
646
  **Reference:** `modules/chore/` — single-job smoke-test module.
646
647
 
647
- ### 8. Translations (`translations/en-US.json`)
648
+ ### 8. Translations (`translations/de-DE.json`)
649
+
650
+ One file per locale in `manifest.supportedLocales`. There is **no** English file: install skips `translations/en.json` and `en-*.json`, and the English UI shows the definitions' own text — an entity's or field's `description`, a view's, menu's, action's or report's `label`, a role's or setting's `description`. Keep those short labels.
648
651
 
649
652
  ```json
650
653
  {
651
654
  "entities": {
652
655
  "department": {
653
- "label": "Department",
654
- "desc": "Company departments",
656
+ "label": "Abteilung",
657
+ "desc": "Abteilungen des Unternehmens",
655
658
  "fields": {
656
- "department_id": { "label": "Department ID" },
657
- "department_name": { "label": "Department Name" }
659
+ "department_id": { "label": "Abteilungs-ID" },
660
+ "department_name": { "label": "Abteilungsname" }
658
661
  }
659
662
  },
660
663
  "employee": {
661
664
  "constraints": {
662
- "chk_salary_positive": { "message": "Salary must be positive" }
665
+ "chk_salary_positive": { "message": "Das Gehalt muss positiv sein" }
663
666
  }
664
667
  }
665
668
  },
666
669
  "folders": {
667
- "hr": { "label": "Human Resources" }
670
+ "hr": { "label": "Personalwesen" }
668
671
  },
669
672
  "views": {
670
- "departments": { "label": "Departments" },
671
- "employees": { "label": "All Employees" }
673
+ "departments": { "label": "Abteilungen" },
674
+ "employees": { "label": "Alle Mitarbeiter" }
672
675
  },
673
676
  "menus": {
674
- "hr_menu": { "label": "Human Resources" }
677
+ "hr_menu": { "label": "Personalwesen" }
675
678
  },
676
679
  "actions": {
677
- "approve_leave": { "label": "Approve Leave", "desc": "Approve a leave request" }
680
+ "approve_leave": { "label": "Urlaub genehmigen", "desc": "Einen Urlaubsantrag genehmigen" }
678
681
  }
679
682
  }
680
683
  ```
@@ -683,7 +686,7 @@ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/a
683
686
 
684
687
  **Constraint violation messages are localizable (opt-in).** The `message` on a check/unique constraint in the entity JSON is the base (fallback) text. To localize it, add a per-locale override under `entities.<entityCd>.constraints.<constraintName>.message` in each `translations/<locale>.json`. The server resolves it with culture fallback (per-locale → base) and it surfaces identically on the client pre-save validator and the server DB-violation path. Unlike labels, constraint overrides are **not** completeness-enforced: a missing override for a declared `supportedLocales` entry emits a **non-fatal warning** (from `dforge_module_validate` pre-flight and at install) and the base message is used as the fallback.
685
688
 
686
- **Silently ignored at runtime:** `roles` and `print_templates` — the resource rows have no `res_id`, so translations are reserved for future use. `settings` is validated for completeness (when listed in `supportedLocales`) but the registrar does not display the translated labels, so don't expect localized output. Display names for these come from the source manifest (`description` for roles, `label` for print templates and settings).
689
+ **Roles and settings are translated; print templates are not.** `roles.<code>.label` and `settings.<code>.label` are completeness-enforced for each declared locale, over the English `description` in `security/roles.json` and `settings.json` (the registrar reads no `label` there). A print template has no translatable resource: its name is its `description` in every language, and a `print_templates` section is ignored.
687
690
 
688
691
  ---
689
692