datagrok-tools 6.5.4 → 6.5.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Datagrok-tools changelog
2
2
 
3
+ ## 6.5.6 (WIP)
4
+
5
+ * `grok report` (`ticket`, `comment`, `label`, `attach`) — support Atlassian **service-account** tokens alongside user API tokens, chosen by the token itself. A user token (`ATATT...`) keeps HTTP Basic with `JIRA_USER` + `JIRA_TOKEN` against the site. A service-account token (`ATSTT...`, minted at admin.atlassian.com) is a scoped token that the site host will not accept — Basic returns 401 and Bearer returns 403 `"Failed to parse Connect Session Auth Token"` — so it is sent as Bearer to the API gateway at `api.atlassian.com/ex/jira/<cloudId>`, with the cloud id read once from the site's public `/_edge/tenant_info` (override with `$JIRA_CLOUD_ID`). `$JIRA_URL` and `--jira-url` still mean the SITE; the gateway address is derived from it, never substituted for it. A service token needs no `JIRA_USER`, so the credential checks no longer demand one.
6
+
7
+ ## 6.5.5 (WIP)
8
+
9
+ * GROK-20298: `grok api --ui` — a relation-bearing table's `<Table>Ui` handle carries `<Table>Update` as its fifth generic too (`<Table>Ui.client`, `.table()` and the `<Schema>UiDb` property), so `<table>Ui.client.update(id, {labels: [...]})` compiles instead of failing the excess-property check.
10
+ * GROK-20298: `grok api` — typed many-to-many relations: a table's `relations` block is validated with the server's rules (via/target declared in the same manifest, junction distinct from both sides, self-referential relations explicit, FK sides unique-or-explicit, junction `businessKey` covering both) and emitted as an expand-map entry `'<name>': {<name>?: {id: string; name: string}[]}`, a `<name>?: string[]` link set on `<Table>Insert`, and a new `<Table>Update` type (`Partial<<Table>Row>` plus the link sets) used by the transaction op union and passed as the client's fifth generic, so `update()` takes the link sets directly. Manifests without relations generate byte-identically.
11
+ * GROK-20298: `grok api --ui` — also emits the typed SCHEMA handle: `export function <schema>UiDb(): Promise<<Schema>UiDb>` (a typed `domains.db(...)`), and the `<Schema>UiDb` interface types each table property (`const db = await gritUiDb(); db.issues.form({values: {...}})` is compile-checked).
12
+ * GROK-20298: **BREAKING** — `grok api` schema clients name their table properties with the camelCase PLURAL of the table name (`<schema>Db.issues`, `.issueLabels`; same rule as domain-ui's `DomainDb`), and no longer pass `datetimeColumns` config — datetime materialization is registry-driven in `DomainTableClient` itself. Regenerate and rename call sites (`<schema>Db.issue` → `<schema>Db.issues`); transaction op `table:` values keep the declared singular names.
13
+ * GROK-20298: `grok add app --domain <schema>[.<table>]` — scaffolds a working browse/CRUD app over an entity-mapped domain table into an existing package: a one-expression `#app` function per table (`return (await domains.table('<schema>.<table>')).app();`, giving list/search/entity page/editing/permissions/deep links from the `@datagrok-libraries/domain-ui` defaults), the domain-ui dependency, and the typed clients for manifests the package declares. `--domain <schema>` covers every table of a schema the package declares; `--domain <path to schema.json>` copies the manifest into `databases/<schema>/` first. A fresh app package is `grok create <Name>` + this command.
14
+ * GROK-20298: `grok api --ui` — emits `src/generated/db-ui.ts` next to `db.ts`: a `<table>Ui` wrapper per domain table over the reflective `@datagrok-libraries/domain-ui` components, with this table's columns, expand keys and insert payloads checked at compile time. `<Table>Ui.table()` resolves the TYPED `DomainTable` handle (all four client generics), and `form`, `formDialog`, `grid`, `list`, `listView`, `app` and `entityView` are built on it; `edit`, `pick`, `row`, `query` and the typed `client` are unchanged, and the address moved from `<Table>Ui.table` to `<Table>Ui.address` to free the name for the handle. Opt-in and separate on purpose — data-only consumers keep `db.ts` free of any UI dependency; once the file exists, plain `grok api` keeps it up to date (delete it to opt out).
15
+ * GROK-20603: `grok api` — the generated `<schema>Db.transaction` signature is now mapped-tuple generic (`<T extends <Schema>TransactionOp[]>(ops: [...T])` with per-op result types); regenerating produces a different file, and positional `as DG.DomainUpdateResult`-style casts on transaction results become unnecessary.
16
+ * GROK-20602: **BREAKING** — `grok api` domain codegen v2: **datetime columns (including `created_on`/`updated_on`) are now typed as dayjs `Dayjs`** in `<Table>Row` (`Dayjs | string` on inserts) and generated clients pass `datetimeColumns` so JSON reads materialize dayjs objects — code that treated these fields as strings no longer compiles (untyped `table('s.t')` clients are unchanged). Also: `choices` columns emit named literal-union aliases used in Row/Insert; `<Table>Column` unions and NEW `<Table>Expand` maps are threaded through the client generics (filters/columns/expand keys compile-checked); a typed `<Schema>TransactionOp` union powers `<schema>Db.transaction`; the client map now uses LAZY getters — importing db.ts no longer touches `grok.dapi` at import time. Regenerate with `grok api` and fix datetime call sites. Migration note: choices-alias names are derived as `<Table><Column>`; when two tables' aliases collide with different value sets, the LATER table (manifest order) gets a `<Table>`-suffixed name — reordering manifest tables can therefore rename such aliases; keep table order stable or update imports after reordering.
17
+ * GROK-20319: `grok api` — generated domain clients now pass the `<Table>Insert` interface as the second `DG.DomainTableClient` generic, so `insert()` enforces required columns at compile time (e.g. `insert({})` no longer compiles); over-long client lines wrap before the type.
18
+ * GROK-20317: `grok api` — typed domain-table clients: for packages that declare `databases/<schema>/schema.json` manifests, generates `src/generated/db.ts` with per-table `<Table>Row`/`<Table>Insert` interfaces, `<Table>Column` name unions, and a per-schema `<schema>Db` client map over `grok.dapi.domains` (manifests are validated against `domain-schema.schema.json` first; packages without manifests are untouched).
3
19
  ## 6.5.4 (2026-08-07)
4
20
 
5
21
  * func-gen-plugin — `#meta.comparison` in an `.ivp` model now lands as the `comparison` option on the generated dataframe output (run comparison reads index/split/mode/units defaults from there) instead of function-level meta.
@@ -16,7 +32,7 @@
16
32
 
17
33
  * `grok test` — the Node-pass report now merges into the browser report by column name. The line-wise merge assumed identical column order, so node rows landed misaligned (string values in the integer `ms` column) and the CI test-report upload failed with `invalid input syntax for type integer`.
18
34
 
19
- ## 6.5.0 (WIP)
35
+ ## 6.5.5 (WIP)
20
36
 
21
37
  * `grok test` — added a Node (browserless) pass: tests annotated `{node: true}` run headless under the js-api Node runtime before the browser launches; the browser pass excludes them and is skipped entirely when nothing browser-only matches. New flags: `--skip-node`, `--node-only`. Packages opt in by exporting `testNode()` from `package-test.ts`; others keep the previous behavior.
22
38
 
@@ -10,12 +10,17 @@ var _path = _interopRequireDefault(require("path"));
10
10
  var _entHelpers = require("../utils/ent-helpers");
11
11
  var utils = _interopRequireWildcard(require("../utils/utils"));
12
12
  var color = _interopRequireWildcard(require("../utils/color-utils"));
13
+ var _api = require("./api");
13
14
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
15
+ /** The domain-ui library version a scaffolded domain app depends on. */
16
+ const domainUiDependency = '^0.1.0';
14
17
  function add(args) {
15
- const nOptions = Object.keys(args).length - 1;
18
+ // `--domain` is the only option any `add` entity takes (`grok add app --domain`).
19
+ const nOptions = Object.keys(args).length - 1 - (args.domain === undefined ? 0 : 1);
16
20
  const nArgs = args['_'].length;
17
21
  if (nArgs < 2 || nArgs > 5 || nOptions > 0) return false;
18
22
  const entity = args['_'][1];
23
+ if (args.domain !== undefined && entity !== 'app') return color.error('`--domain` applies to `grok add app` only');
19
24
  const curDir = process.cwd();
20
25
  const curFolder = _path.default.basename(curDir);
21
26
  const srcDir = _path.default.join(curDir, 'src');
@@ -60,6 +65,112 @@ function add(args) {
60
65
  _fs.default.writeFileSync(packageEntry, contents, 'utf8');
61
66
  }
62
67
  }
68
+
69
+ /** The manifest at [manifestPath], or null when it is missing or unreadable. */
70
+ function readManifest(manifestPath) {
71
+ if (!_fs.default.existsSync(manifestPath)) return null;
72
+ try {
73
+ const manifest = JSON.parse(_fs.default.readFileSync(manifestPath, 'utf8'));
74
+ if (typeof manifest?.name !== 'string' || manifest?.tables == null) {
75
+ color.error(`${manifestPath} is not a domain schema manifest (no \`name\` / \`tables\`)`);
76
+ return null;
77
+ }
78
+ return manifest;
79
+ } catch (error) {
80
+ color.error(`Error while reading ${manifestPath}:`);
81
+ console.error(error);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** Copies a schema manifest into `databases/<schema>/` (where `grok api` and the
87
+ * publisher look for it); returns its schema name and table names. */
88
+ function copyManifest(manifestPath) {
89
+ const manifest = readManifest(_path.default.resolve(curDir, manifestPath));
90
+ if (manifest == null) return null;
91
+ const target = _path.default.join(curDir, 'databases', manifest.name, 'schema.json');
92
+ if (_path.default.resolve(curDir, manifestPath) !== target) {
93
+ _fs.default.mkdirSync(_path.default.dirname(target), {
94
+ recursive: true
95
+ });
96
+ _fs.default.copyFileSync(_path.default.resolve(curDir, manifestPath), target);
97
+ console.log(`Copied the schema manifest to databases/${manifest.name}/schema.json`);
98
+ }
99
+ return [manifest.name, Object.keys(manifest.tables)];
100
+ }
101
+
102
+ /** Adds `import {domains} from '@datagrok-libraries/domain-ui';` to the package
103
+ * entry file, after its existing imports (which webpack's externals depend on). */
104
+ function addDomainUiImport() {
105
+ const contents = _fs.default.readFileSync(packageEntry, 'utf8');
106
+ if (contents.includes('@datagrok-libraries/domain-ui')) return;
107
+ const eol = contents.includes('\r\n') ? '\r\n' : '\n';
108
+ const line = `import {domains} from '@datagrok-libraries/domain-ui';`;
109
+ const lines = contents.split(/\r?\n/);
110
+ let last = -1;
111
+ for (let i = 0; i < lines.length; i++) if (/^(import .*|export .* from .*);\s*$/.test(lines[i])) last = i;
112
+ lines.splice(last + 1, 0, line);
113
+ _fs.default.writeFileSync(packageEntry, lines.join(eol), 'utf8');
114
+ }
115
+
116
+ /** `grok add app [name] --domain <schema>[.<table>] | <schema.json path>` — a working
117
+ * browse/CRUD app per table, from the `domain-ui` defaults alone. */
118
+ function addDomainApp(domainArg, appName) {
119
+ if (typeof domainArg !== 'string' || domainArg === '') return color.error('`--domain` needs `<schema>` or `<schema>.<table>`, ' + 'or a path to a schema.json manifest');
120
+ let schema;
121
+ let tables;
122
+ if (/\.json$/i.test(domainArg)) {
123
+ const copied = copyManifest(domainArg);
124
+ if (copied == null) return false;
125
+ [schema, tables] = copied;
126
+ } else {
127
+ if (!/^[A-Za-z_]\w*(\.[A-Za-z_]\w*)?$/.test(domainArg)) return color.error(`Malformed domain address: ${domainArg}. ` + 'Use `<schema>`, `<schema>.<table>`, or a path to a schema.json manifest');
128
+ const dot = domainArg.indexOf('.');
129
+ schema = dot === -1 ? domainArg : domainArg.slice(0, dot);
130
+ if (dot !== -1) tables = [domainArg.slice(dot + 1)];else {
131
+ // Table names of a whole schema are only known offline from its manifest.
132
+ const manifest = readManifest(_path.default.join(curDir, 'databases', schema, 'schema.json'));
133
+ if (manifest == null) return color.error(`No \`databases/${schema}/schema.json\` in this package: name a table ` + `(\`--domain ${schema}.<table>\`) or pass the path to the schema manifest`);
134
+ tables = Object.keys(manifest.tables);
135
+ }
136
+ }
137
+ if (tables.length === 0) return color.error(`Schema \`${schema}\` declares no tables`);
138
+ if (appName != null && tables.length > 1) return color.error(`\`--domain ${domainArg}\` covers ${tables.length} tables — ` + 'name a single table to name its app');
139
+ createPackageEntryFile();
140
+ const template = _fs.default.readFileSync(_path.default.join(templateDir, 'entity-template', 'domain-app' + ext), 'utf8');
141
+ const entry = _fs.default.readFileSync(packageEntry, 'utf8');
142
+ const added = [];
143
+ const addedTables = [];
144
+ for (const table of tables) {
145
+ // No 'App' postfix — `grok check` asks apps to drop it (check.ts:350-356).
146
+ const funcName = appName ?? utils.snakeToCamelCase(table);
147
+ if (!validateName(funcName)) return false;
148
+ if (new RegExp(`function\\s+${funcName}\\s*\\(`).test(entry)) {
149
+ color.warn(`${funcName} is already declared in ${_path.default.relative(curDir, packageEntry)} — skipped`);
150
+ continue;
151
+ }
152
+ _fs.default.appendFileSync(packageEntry, utils.replacers['DOMAIN_TABLE'](insertName(funcName, template), `${schema}.${table}`));
153
+ added.push(funcName);
154
+ addedTables.push(`${schema}.${table}`);
155
+ }
156
+ if (added.length === 0) return true;
157
+ addDomainUiImport();
158
+ const packageObj = JSON.parse(_fs.default.readFileSync(packagePath, 'utf8'));
159
+ packageObj.dependencies = packageObj.dependencies ?? {};
160
+ if (packageObj.dependencies['@datagrok-libraries/domain-ui'] === undefined) {
161
+ packageObj.dependencies['@datagrok-libraries/domain-ui'] = domainUiDependency;
162
+ _fs.default.writeFileSync(packagePath, JSON.stringify(packageObj, null, 2), 'utf8');
163
+ }
164
+
165
+ // Manifests in the package get typed clients AND the typed UI wrappers the app
166
+ // code can switch to; a schema deployed by another package has none, and the
167
+ // reflective app above needs none.
168
+ if (!(0, _api.generateDomainClients)(curDir, {
169
+ ui: true
170
+ })) return false;
171
+ console.log(_entHelpers.help.domainApp(added, addedTables));
172
+ return true;
173
+ }
63
174
  let name;
64
175
  let tag;
65
176
  let contents;
@@ -104,6 +215,10 @@ function add(args) {
104
215
  console.log(_entHelpers.help.script(name, curFolder));
105
216
  break;
106
217
  case 'app':
218
+ if (args.domain !== undefined) {
219
+ if (nArgs > 3) return false;
220
+ return addDomainApp(args.domain, nArgs === 3 ? args['_'][2] : null);
221
+ }
107
222
  if (nArgs !== 3) return false;
108
223
 
109
224
  // App name check
@@ -5,16 +5,56 @@ Object.defineProperty(exports, "__esModule", {
5
5
  value: true
6
6
  });
7
7
  exports.api = api;
8
+ exports.generateDomainClients = generateDomainClients;
8
9
  var _fs = _interopRequireDefault(require("fs"));
9
10
  var _path = _interopRequireDefault(require("path"));
10
11
  var _ignoreWalk = _interopRequireDefault(require("ignore-walk"));
12
+ var _ajv = _interopRequireDefault(require("ajv"));
11
13
  var utils = _interopRequireWildcard(require("../utils/utils"));
12
14
  var color = _interopRequireWildcard(require("../utils/color-utils"));
13
15
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
14
16
  const annotationForApiFile = `/**\nThis file is auto-generated by the grok api command.\nIf you notice any changes, please push them to the repository.\nDo not edit this file manually.\n*/\n`;
17
+ const annotationForDbFile = `/**\nThis file is auto-generated by the grok api command from databases/<schema>/schema.json manifests.\nIf you notice any changes, please push them to the repository.\nDo not edit this file manually.\n*/\n`;
18
+ const annotationForDbUiFile = `/**\nThis file is auto-generated by the grok api command (--ui) from databases/<schema>/schema.json manifests.\nIt is regenerated on every \`grok api\` run as long as it exists; delete it to opt out.\nIf you notice any changes, please push them to the repository.\nDo not edit this file manually.\n*/\n`;
15
19
  const sep = '\n';
16
20
  const packageFuncDirs = ['package.ts', 'package.g.ts'];
17
21
  const apiFile = 'package-api.ts';
22
+ const dbFile = 'db.ts';
23
+ const dbUiFile = 'db-ui.ts';
24
+ /** What `db-ui.ts` imports from `@datagrok-libraries/domain-ui`. */
25
+ const domainUiImports = ['domainHandler', 'domains', 'DomainAppView', 'DomainAppViewOptions', 'DomainDb', 'DomainDialogOptions', 'DomainEntityAppView', 'DomainEntityAppViewOptions', 'DomainForm', 'DomainFormOptions', 'DomainGrid', 'DomainGridOptions', 'DomainTable', 'EntityListOptions', 'EntityListWidget'];
26
+ /** {@link DomainDb} members a table's camelCase property name must not shadow. */
27
+ const domainDbReservedProps = ['name', 'tables', 'table', 'acquire'];
28
+
29
+ /** Naive English plural of a (snake_case) table name — `issue` → `issues`, `box` →
30
+ * `boxes`, `category` → `categories`. MUST match domain-ui's `pluralizeTableName`:
31
+ * the runtime `DomainDb` assigns its per-table properties with the same rule, so the
32
+ * generated typed interfaces bind to it. */
33
+ function pluralizeTableName(name) {
34
+ if (/(s|x|z|ch|sh)$/.test(name)) return name + 'es';
35
+ if (/[^aeiou_]y$/.test(name)) return name.slice(0, -1) + 'ies';
36
+ return name + 's';
37
+ }
38
+
39
+ /** The camelCase plural property name a table gets on the schema client and the
40
+ * schema UI handle (`issue_label` → `issueLabels`). */
41
+ function tableProp(tableName) {
42
+ return utils.snakeToCamelCase(pluralizeTableName(tableName), false);
43
+ }
44
+ const domainSchemaPath = _path.default.join(_path.default.dirname(_path.default.dirname(__dirname)), 'domain-schema.schema.json');
45
+ const domainSystemColumns = [['id', 'string'], ['version', 'number'], ['created_on', 'Dayjs'], ['updated_on', 'Dayjs'], ['author_id', 'string']];
46
+ const domainTypeMap = {
47
+ string: 'string',
48
+ int: 'number',
49
+ float: 'number',
50
+ bool: 'boolean',
51
+ datetime: 'Dayjs',
52
+ string_list: 'string[]',
53
+ ref: 'string',
54
+ user: 'string',
55
+ group: 'string',
56
+ file: 'string'
57
+ };
18
58
  function normEol(s) {
19
59
  return s.replace(/\r\n/g, '\n');
20
60
  }
@@ -130,13 +170,350 @@ function checkNameColision(name) {
130
170
  if (names.has(name)) console.log('There is collision in name ' + name);
131
171
  names.add(name);
132
172
  }
173
+
174
+ /** Generates `src/generated/db.ts` with typed clients for the package's domain schemas
175
+ * (`databases/<schema>/schema.json` manifests), and — with [options].ui, or whenever the
176
+ * file is already there — `src/generated/db-ui.ts` with the typed UI sugar over
177
+ * `@datagrok-libraries/domain-ui`. No-op for packages without manifests. */
178
+ function generateDomainClients(packageDir = curDir, options) {
179
+ const databasesDir = _path.default.join(packageDir, 'databases');
180
+ if (!_fs.default.existsSync(databasesDir)) return true;
181
+ const manifestPaths = _fs.default.readdirSync(databasesDir).map(dir => _path.default.join(databasesDir, dir, 'schema.json')).filter(p => _fs.default.existsSync(p));
182
+ if (manifestPaths.length === 0) return true;
183
+ const validateManifest = new _ajv.default().compile(JSON.parse(_fs.default.readFileSync(domainSchemaPath, 'utf8')));
184
+ const genDir = _path.default.join(packageDir, 'src', 'generated');
185
+ // The `--ui` opt-in persists by presence: once emitted, the file is kept up to date by
186
+ // every plain `grok api` run, so a package's build script needs no extra flag.
187
+ const ui = (options?.ui ?? false) || _fs.default.existsSync(_path.default.join(genDir, dbUiFile));
188
+ const parts = [];
189
+ const uiParts = [];
190
+ const dbImports = [];
191
+ const emittedTypes = new Set();
192
+ for (const manifestPath of manifestPaths) {
193
+ const relPath = _path.default.relative(packageDir, manifestPath);
194
+ let manifest;
195
+ try {
196
+ manifest = JSON.parse(_fs.default.readFileSync(manifestPath, 'utf8'));
197
+ } catch (x) {
198
+ color.error(`Failed to parse ${relPath}: ${x.message}`);
199
+ return false;
200
+ }
201
+ if (!validateManifest(manifest)) {
202
+ for (const err of validateManifest.errors ?? []) color.error(`${relPath}: ${err.instancePath || '/'} ${err.message}`);
203
+ return false;
204
+ }
205
+ const code = generateDomainSchemaCode(manifest, relPath, emittedTypes);
206
+ if (code == null) return false;
207
+ parts.push(code);
208
+ if (ui) uiParts.push(generateDomainUiCode(manifest, dbImports));
209
+ }
210
+ _fs.default.mkdirSync(genDir, {
211
+ recursive: true
212
+ });
213
+ const content = annotationForDbFile + utils.dgImports + `import type {Dayjs} from 'dayjs';${sep}` + sep + parts.join(sep);
214
+ _fs.default.writeFileSync(_path.default.join(genDir, dbFile), normEol(content).replace(/\n/g, '\r\n'), 'utf8');
215
+ color.log(`Successfully generated file src/generated/${dbFile}${sep}`, 'success');
216
+ if (ui) {
217
+ const uiContent = annotationForDbUiFile + `import * as DG from 'datagrok-api/dg';${sep}` + wrapTokens(`import {`, domainUiImports, ', ', `} from '@datagrok-libraries/domain-ui';`) + sep + wrapTokens(`import {`, dbImports, ', ', `} from './${dbFile.replace(/\.ts$/, '')}';`) + sep + sep + uiParts.join(sep);
218
+ _fs.default.writeFileSync(_path.default.join(genDir, dbUiFile), normEol(uiContent).replace(/\n/g, '\r\n'), 'utf8');
219
+ color.log(`Successfully generated file src/generated/${dbUiFile}${sep}`, 'success');
220
+ }
221
+ return true;
222
+ }
223
+
224
+ /** A resolved many-to-many relation of one table: the expand key and the target
225
+ * table whose row ids its write-side link set carries. */
226
+
227
+ /** Emits choices aliases, row/insert interfaces, column-name unions, expand maps, the
228
+ * `<Schema>TransactionOp` union, and the lazy `<schema>Db` clients for one manifest.
229
+ * Returns null on a semantic error (reported to the console). */
230
+ function generateDomainSchemaCode(manifest, manifestPath, emittedTypes) {
231
+ const decls = [];
232
+ const systemColumnNames = new Set(domainSystemColumns.map(([name]) => name));
233
+ const tableNames = Object.keys(manifest.tables);
234
+ const tableColumns = {};
235
+ // Choices aliases are deduplicated by name: identical value sets share the first alias,
236
+ // different sets fall back to a `<alias><PascalTable>` name (deterministic).
237
+ const choicesAliases = {};
238
+ const choicesAlias = (tableName, columnName, values) => {
239
+ const valueSet = values.map(v => `'${v}'`).join(' | ');
240
+ let alias = utils.snakeToCamelCase(tableName) + utils.snakeToCamelCase(columnName);
241
+ if (choicesAliases[alias] !== undefined && choicesAliases[alias] !== valueSet) alias += utils.snakeToCamelCase(tableName);
242
+ if (choicesAliases[alias] !== undefined && choicesAliases[alias] !== valueSet) {
243
+ color.error(`${manifestPath}: cannot derive a unique choices alias for '${tableName}.${columnName}'`);
244
+ return null;
245
+ }
246
+ if (choicesAliases[alias] === undefined) {
247
+ choicesAliases[alias] = valueSet;
248
+ decls.push(wrapTokens(`export type ${alias} = `, values.map(v => `'${v}'`), ' | ', ';'));
249
+ }
250
+ return alias;
251
+ };
252
+
253
+ // Pass 1: resolve every table's full column list (relational + property-schema columns).
254
+ for (const tableName of tableNames) {
255
+ const table = manifest.tables[tableName];
256
+ const typeName = utils.snakeToCamelCase(tableName);
257
+ if (emittedTypes.has(typeName)) {
258
+ color.error(`${manifestPath}: table '${tableName}' emits interface '${typeName}Row' ` + `already generated for another table (table names must be unique across the package's manifests)`);
259
+ return null;
260
+ }
261
+ emittedTypes.add(typeName);
262
+ const columns = [];
263
+ const columnNames = new Set(systemColumnNames);
264
+ const addColumn = (name, column) => {
265
+ if (columnNames.has(name)) {
266
+ color.error(systemColumnNames.has(name) ? `${manifestPath}: table '${tableName}' column '${name}' collides with a generated system column` : `${manifestPath}: table '${tableName}' declares duplicate column '${name}'`);
267
+ return false;
268
+ }
269
+ columnNames.add(name);
270
+ let tsType = domainTypeMap[column.type];
271
+ if (Array.isArray(column.choices) && column.choices.length > 0) {
272
+ const alias = choicesAlias(tableName, name, column.choices);
273
+ if (alias == null) return false;
274
+ tsType = alias;
275
+ }
276
+ columns.push({
277
+ name: name,
278
+ rawType: column.type,
279
+ tsType: tsType,
280
+ insertType: column.type === 'datetime' ? 'Dayjs | string' : tsType,
281
+ required: column.required === true,
282
+ ref: column.type === 'ref' && manifest.tables[column.ref] != null ? column.ref : undefined
283
+ });
284
+ return true;
285
+ };
286
+ for (const columnName of Object.keys(table.columns)) if (!addColumn(columnName, table.columns[columnName])) return null;
287
+ for (const schemaName of table.schemas ?? []) {
288
+ const props = manifest.propertySchemas?.[schemaName];
289
+ if (props == null) {
290
+ color.error(`${manifestPath}: table '${tableName}' references unknown property schema '${schemaName}'`);
291
+ return null;
292
+ }
293
+ for (const propName of Object.keys(props)) if (!addColumn(propName, props[propName])) return null;
294
+ }
295
+ tableColumns[tableName] = columns;
296
+ }
297
+
298
+ // Pass 1.5: resolve declared many-to-many relations. Same rules the server's
299
+ // manifest parser applies (manifest.dart 'relations'), so a manifest that
300
+ // generates here is one that deploys: via/target declared in this manifest,
301
+ // via distinct from owner and target, self-referential relations explicit,
302
+ // FK sides unique-or-explicit, and a junction business key covering both —
303
+ // that key is what keeps re-linking the same pair idempotent.
304
+ const tableRelations = {};
305
+ for (const tableName of tableNames) {
306
+ const relations = [];
307
+ tableRelations[tableName] = relations;
308
+ const declared = manifest.tables[tableName].relations;
309
+ if (declared == null) continue;
310
+ const ownColumns = new Set(tableColumns[tableName].map(c => c.name));
311
+ for (const name of Object.keys(declared)) {
312
+ const r = declared[name];
313
+ const where = `${manifestPath}: relation '${tableName}.${name}'`;
314
+ if (ownColumns.has(name) || systemColumnNames.has(name)) {
315
+ color.error(`${where} collides with a column of '${tableName}' — relations and columns ` + `share one expand/filter namespace`);
316
+ return null;
317
+ }
318
+ if (manifest.tables[r.via] == null) {
319
+ color.error(`${where}: junction table '${r.via}' is not declared in this manifest`);
320
+ return null;
321
+ }
322
+ if (manifest.tables[r.target] == null) {
323
+ color.error(`${where}: target table '${r.target}' is not declared in this manifest`);
324
+ return null;
325
+ }
326
+ if (r.via === tableName || r.via === r.target) {
327
+ color.error(`${where}: junction table '${r.via}' must differ from the owner and the target table`);
328
+ return null;
329
+ }
330
+ if (r.target === tableName && (r.viaSelf == null || r.viaTarget == null)) {
331
+ color.error(`${where}: a self-referential relation must name both 'viaSelf' and 'viaTarget'`);
332
+ return null;
333
+ }
334
+ // One side of the junction: the declared column when explicit, otherwise
335
+ // the single ref column of `via` pointing at `to`.
336
+ const resolveSide = (key, explicit, to) => {
337
+ const candidates = tableColumns[r.via].filter(c => c.ref === to).map(c => c.name);
338
+ if (explicit != null) {
339
+ if (candidates.includes(explicit)) return explicit;
340
+ color.error(`${where}: '${explicit}' is not a ref column of junction table '${r.via}' ` + `targeting '${to}'`);
341
+ return null;
342
+ }
343
+ if (candidates.length === 0) {
344
+ color.error(`${where}: junction table '${r.via}' has no ref column targeting '${to}'`);
345
+ return null;
346
+ }
347
+ if (candidates.length > 1) {
348
+ color.error(`${where}: junction table '${r.via}' has more than one ref column targeting ` + `'${to}' (${candidates.join(', ')}) — declare '${key}' explicitly`);
349
+ return null;
350
+ }
351
+ return candidates[0];
352
+ };
353
+ const viaSelf = resolveSide('viaSelf', r.viaSelf, tableName);
354
+ const viaTarget = resolveSide('viaTarget', r.viaTarget, r.target);
355
+ if (viaSelf == null || viaTarget == null) return null;
356
+ if (viaSelf === viaTarget) {
357
+ color.error(`${where}: 'viaSelf' and 'viaTarget' must be different columns of '${r.via}'`);
358
+ return null;
359
+ }
360
+ const businessKey = manifest.tables[r.via].businessKey ?? [];
361
+ if (!businessKey.includes(viaSelf) || !businessKey.includes(viaTarget)) {
362
+ color.error(`${where}: junction table '${r.via}' must declare a 'businessKey' containing ` + `both '${viaSelf}' and '${viaTarget}', so linking the same pair twice stays idempotent`);
363
+ return null;
364
+ }
365
+ relations.push({
366
+ name: name,
367
+ target: r.target
368
+ });
369
+ }
370
+ }
371
+
372
+ // Pass 2: emit per-table declarations.
373
+ const clients = [];
374
+ const txArms = [];
375
+ for (const tableName of tableNames) {
376
+ const table = manifest.tables[tableName];
377
+ const typeName = utils.snakeToCamelCase(tableName);
378
+ const columns = tableColumns[tableName];
379
+ const relations = tableRelations[tableName];
380
+ const rowLines = [`/** Row of \`${manifest.name}.${tableName}\`. */`, `export interface ${typeName}Row {`];
381
+ for (const [name, tsType] of domainSystemColumns) rowLines.push(` ${name}: ${tsType};`);
382
+ for (const c of columns) rowLines.push(` ${c.name}${c.required ? '' : '?'}: ${c.tsType};`);
383
+ rowLines.push('}');
384
+ decls.push(rowLines.join(sep));
385
+ const insertLines = [`/** Insert payload for \`${manifest.name}.${tableName}\`. */`, `export interface ${typeName}Insert {`];
386
+ for (const c of columns) insertLines.push(` ${c.name}${c.required ? '' : '?'}: ${c.insertType};`);
387
+ for (const r of relations) insertLines.push(` /** Link set: \`${r.target}\` row ids. */`, ` ${r.name}?: string[];`);
388
+ if (table.idempotency === true) insertLines.push(' idempotencyKey?: string;');
389
+ insertLines.push('}');
390
+ decls.push(insertLines.join(sep));
391
+
392
+ // Relations are write-side values, not columns: a patch takes them alongside
393
+ // the declared ones (set-replace over the links the caller can see), while
394
+ // reads surface them through the expand map below.
395
+ if (relations.length > 0) {
396
+ const updateLines = [`/** Update payload for \`${manifest.name}.${tableName}\`: declared ` + `columns plus relation link sets (each REPLACES the links you can see). */`, `export type ${typeName}Update = Partial<${typeName}Row> & {`];
397
+ for (const r of relations) updateLines.push(` ${r.name}?: string[];`);
398
+ updateLines.push('};');
399
+ decls.push(updateLines.join(sep));
400
+ }
401
+ decls.push(formatColumnUnion(`${typeName}Column`, [...domainSystemColumns.map(([name]) => name), ...columns.map(c => c.name)]));
402
+
403
+ // Expand map: MUST stay a `type` alias — object-type literals carry the implicit
404
+ // index signature the `TExpand extends {[key: string]: {}}` constraint needs
405
+ // (an interface would fail it).
406
+ const expandEntries = [];
407
+ for (const c of columns) {
408
+ if (c.ref == null) continue;
409
+ const fields = tableColumns[c.ref].map(tc => `'${c.name}.${tc.name}'?: ${tc.tsType}`);
410
+ expandEntries.push(wrapTokens(` '${c.name}': {`, fields, '; ', '};', ' '));
411
+ }
412
+ // A relation expands to the capped, display-name-ordered link array; the d42
413
+ // form (queryDf) flattens it into a chips column plus its '~<name>.id' companion.
414
+ // The link shape stays INLINE (structurally DG.DomainRelationLink): generated
415
+ // files must compile against the datagrok-api the package depends on, which may
416
+ // predate that interface.
417
+ for (const r of relations) expandEntries.push(` '${r.name}': {${r.name}?: {id: string; name: string}[]};`);
418
+ for (const childName of tableNames) {
419
+ const fks = tableColumns[childName].filter(c => c.ref === tableName);
420
+ if (fks.length === 0) continue;
421
+ const childType = utils.snakeToCamelCase(childName);
422
+ if (fks.length === 1) expandEntries.push(` 'details:${childName}': {${childName}?: ${childType}Row[]};`);else for (const fk of fks) expandEntries.push(` 'details:${childName}.${fk.name}': {${childName}?: ${childType}Row[]};`);
423
+ }
424
+ decls.push(expandEntries.length === 0 ? `export type ${typeName}Expand = {};` : [`/** Expand keys of \`${manifest.name}.${tableName}\` → fields each adds to the row ` + `(consumed by query()/builder). */`, `export type ${typeName}Expand = {`, ...expandEntries, '};'].join(sep));
425
+ txArms.push(` {op: 'insert'; table: '${tableName}'; ref?: string; values: DG.DomainTxValues<${typeName}Insert>} |`, ` {op: 'update'; table: '${tableName}'; id: string; ` + `values: DG.DomainTxValues<${relations.length > 0 ? `${typeName}Update` : `Partial<${typeName}Row>`}>; ` + `expectedVersion?: number} |`, ` {op: 'delete'; table: '${tableName}'; id: string} |`);
426
+
427
+ // No datetime config: the client resolves datetime columns from the domain
428
+ // registry itself (the Dayjs typing in <Table>Row stays true for every client).
429
+ // The fifth generic is the update payload — only a relation-bearing table has
430
+ // one of its own; without relations the client's `Partial<Row>` default is it.
431
+ clients.push(` get ${tableProp(tableName)}() {`, ` return grok.dapi.domains.table<${typeName}Row, ${typeName}Insert, ` + `${typeName}Column, ${typeName}Expand` + `${relations.length > 0 ? `, ${typeName}Update` : ''}>('${manifest.name}.${tableName}');`, ' },');
432
+ }
433
+ const schemaType = utils.snakeToCamelCase(manifest.name);
434
+ const lastArm = txArms.pop();
435
+ txArms.push(lastArm.replace(/ \|$/, ';'));
436
+ decls.push([`export type ${schemaType}TransactionOp =`, ...txArms].join(sep));
437
+ decls.push([`/** Typed clients for the \`${manifest.name}\` domain schema tables ` + `(lazy — no import-time side effects). */`, `export const ${utils.snakeToCamelCase(manifest.name, false)}Db = {`, ...clients, ` transaction<T extends ${schemaType}TransactionOp[]>(ops: [...T]):`, ` Promise<{[K in keyof T]: DG.DomainOpResultFor<T[K]>}> {`, ` return grok.dapi.domains.transaction('${manifest.name}', ops) as`, ` Promise<{[K in keyof T]: DG.DomainOpResultFor<T[K]>}>;`, ' },', '};'].join(sep));
438
+ return decls.join(sep.repeat(2)) + sep;
439
+ }
440
+
441
+ /** Emits the typed UI sugar of one manifest: per-table option types and a `<table>Ui`
442
+ * wrapper over the reflective `@datagrok-libraries/domain-ui` components, so app code gets
443
+ * this table's columns and row type checked. Collects the names it needs from `db.ts` into
444
+ * [dbImports]. Nothing here is imported by `db.ts` — data-only consumers gain no UI
445
+ * dependency. */
446
+ function generateDomainUiCode(manifest, dbImports) {
447
+ const decls = [];
448
+ const schemaClient = `${utils.snakeToCamelCase(manifest.name, false)}Db`;
449
+ dbImports.push(schemaClient);
450
+ for (const tableName of Object.keys(manifest.tables)) {
451
+ const type = utils.snakeToCamelCase(tableName);
452
+ const address = `${manifest.name}.${tableName}`;
453
+ // The generics of the client and the handle, in DomainTableClient's order. The
454
+ // fifth — the update payload — exists only for a relation-bearing table, whose
455
+ // update() also takes the link sets; without relations the `Partial<Row>`
456
+ // default is it. Emitting four there would make `update(id, {labels: [...]})`
457
+ // an excess-property error against a payload that has no `labels`.
458
+ const hasRelations = Object.keys(manifest.tables[tableName].relations ?? {}).length > 0;
459
+ const generics = `${type}Row, ${type}Insert, ${type}Column, ${type}Expand` + (hasRelations ? `, ${type}Update` : '');
460
+ const handle = `DomainTable<${generics}>`;
461
+ dbImports.push(`${type}Column`, `${type}Expand`, `${type}Insert`, `${type}Row`);
462
+ if (hasRelations) dbImports.push(`${type}Update`);
463
+ decls.push([`/** Query spec of \`${address}\` — columns and expand keys are compile-checked. */`, `export type ${type}QuerySpec = DG.DomainQuerySpec<${type}Column, keyof ${type}Expand & string>;`].join(sep), [`/** {@link DG.DomainQuery} parameters of \`${address}\` (schema and table are implied). */`, `export interface ${type}QueryParams extends`, ` Omit<DG.DomainQueryParams, 'schema' | 'table' | 'columns'> {`, ` columns?: ${type}Column[];`, '}'].join(sep), [`/** Options of {@link ${type}Ui.form} / {@link ${type}Ui.formDialog}. */`, `export interface ${type}FormOptions extends Omit<DomainFormOptions, 'values'> {`, ` values?: Partial<${type}Insert>;`, '}'].join(sep), [`/** Options of {@link ${type}Ui.grid}. */`, `export interface ${type}GridOptions extends Omit<DomainGridOptions, 'query' | 'defaults'> {`, ` query?: ${type}QuerySpec;`, ` defaults?: Partial<${type}Insert>;`, '}'].join(sep), [`/** Options of {@link ${type}Ui.list}. */`, `export interface ${type}ListOptions extends Omit<EntityListOptions, 'query'> {`, ` query?: ${type}QuerySpec;`, '}'].join(sep), [`/** Options of {@link ${type}Ui.listView} / {@link ${type}Ui.app}. */`, `export interface ${type}AppViewOptions extends Omit<DomainAppViewOptions, 'query'> {`, ` query?: ${type}QuerySpec;`, '}'].join(sep), ['/**', ` * Typed UI over \`${address}\`: the reflective components of`, ' * `@datagrok-libraries/domain-ui`, with this table\'s columns and row type checked at', ` * compile time. Reach it through {@link ${utils.snakeToCamelCase(tableName, false)}Ui}.`, ' */', `export class ${type}Ui {`, ` /** The table address, \`'<schema>.<table>'\`. */`, ` readonly address: string = '${address}';`, '', ` /** The typed client — the same one \`${schemaClient}.${tableProp(tableName)}\` returns. */`, ` get client(): DG.DomainTableClient<${generics}> {`, ` return ${schemaClient}.${tableProp(tableName)};`, ' }', '', ` /** The prefetched handle on \`${address}\` — THE async boundary, typed. Every`, ' * widget factory on it is synchronous, so acquire it ONCE when a page builds', ' * more than one widget; the shortcuts below acquire one of their own. */', ` table(): Promise<${handle}> {`, ` return domains.table<${generics}>(this.client);`, ' }', '', ' /** The handler registered for the table (the reflective default when none is). */', ' handler(): DG.DomainObjectHandler {', ' return domainHandler(this.address);', ' }', '', ' /** The property form of ONE row — a new one by default, an existing one with', ' * `{row}` or `{id}`; the values are this table\'s. */', ` async form(options?: ${type}FormOptions): Promise<DomainForm> {`, ' return (await this.table()).form(options);', ' }', '', ' /** {@link form} in a dialog; resolves to whether a row was saved. */', ` async formDialog(options?: ${type}FormOptions & DomainDialogOptions): Promise<boolean> {`, ' return (await this.table()).formDialog(options);', ' }', '', ' /** The browse/CRUD page: list, search, New, and a deep-linkable query. */', ` async listView(options?: ${type}AppViewOptions): Promise<DomainAppView> {`, ' return (await this.table()).listView(options);', ' }', '', ' /** THE app — {@link listView} under the name that says what it is. */', ` app(options?: ${type}AppViewOptions): Promise<DomainAppView> {`, ' return this.listView(options);', ' }', '', ' /** The row page: form, detail tabs, history. Takes the row or its id. */', ' async entityView(row: string | DG.DomainRow,', ' options?: DomainEntityAppViewOptions): Promise<DomainEntityAppView> {', ' return (await this.table()).entityView(row, options);', ' }', '', ' /** An editable grid: batch editing, one-transaction save. */', ` async grid(options?: ${type}GridOptions): Promise<DomainGrid> {`, ' return (await this.table()).grid(options);', ' }', '', ' /** A list of rows (cards / brief / grid). */', ` async list(options?: ${type}ListOptions): Promise<EntityListWidget> {`, ' return (await this.table()).list(options);', ' }', '', ' /** Opens the platform\'s create dialog, or the edit dialog for [row]. */', ' edit(row?: DG.DomainRow): Promise<boolean> {', ' return this.handler().editRow(row);', ' }', '', ' /** Opens the platform\'s row picker. */', ' pick(): Promise<DG.DomainRow | null> {', ' return this.handler().pickRow();', ' }', '', ' /** Wraps one `query()` row as a {@link DG.DomainRow} — locally, no round trip. */', ` row(values: Partial<${type}Row> | null): DG.DomainRow {`, ' return this.handler().rowFrom(values);', ' }', '', ' /** A serializable query over the table: deep links, saved filters, Open in Table View. */', ` query(params?: ${type}QueryParams): DG.DomainQuery {`, ` return new DG.DomainQuery({...params, schema: '${manifest.name}', table: '${tableName}'});`, ' }', '}'].join(sep), [`/** Typed UI over \`${address}\` (see {@link ${type}Ui}). */`, `export const ${utils.snakeToCamelCase(tableName, false)}Ui = new ${type}Ui();`].join(sep));
464
+ }
465
+
466
+ // The schema-level handle: every table's DomainTable under ONE await, typed
467
+ // (`const db = await <schema>UiDb(); db.<plural>.form(...)`).
468
+ const schemaType = utils.snakeToCamelCase(manifest.name);
469
+ const tableProps = Object.keys(manifest.tables).filter(t => !domainDbReservedProps.includes(tableProp(t)));
470
+ decls.push(['/**', ` * The typed schema handle over \`${manifest.name}\`: one prefetched {@link DomainTable}`, ' * per table, resolved together by {@link ' + utils.snakeToCamelCase(manifest.name, false) + 'UiDb} —', ' * the schema-level async boundary (see `domains.db`).', ' */', `export interface ${schemaType}UiDb extends DomainDb {`, ...tableProps.map(t => {
471
+ const type = utils.snakeToCamelCase(t);
472
+ const update = Object.keys(manifest.tables[t].relations ?? {}).length > 0 ? `, ${type}Update` : '';
473
+ return ` readonly ${tableProp(t)}: ` + `DomainTable<${type}Row, ${type}Insert, ${type}Column, ${type}Expand${update}>;`;
474
+ }), '}'].join(sep), [`/** Acquires the {@link ${schemaType}UiDb} handle — every table of`, ` * \`${manifest.name}\`, prefetched together (see \`domains.db\`). */`, `export function ${utils.snakeToCamelCase(manifest.name, false)}UiDb(): Promise<${schemaType}UiDb> {`, ` return domains.db('${manifest.name}') as Promise<${schemaType}UiDb>;`, '}'].join(sep));
475
+ return decls.join(sep.repeat(2)) + sep;
476
+ }
477
+
478
+ /** Joins [tokens] into `<prefix>t1<sepToken>t2...<suffix>` lines wrapped at the 120-char
479
+ * limit with a hanging [indent]. */
480
+ function wrapTokens(prefix, tokens, sepToken, suffix, indent = ' ') {
481
+ const lines = [];
482
+ let line = prefix;
483
+ for (let i = 0; i < tokens.length; i++) {
484
+ const token = tokens[i] + (i < tokens.length - 1 ? sepToken : suffix);
485
+ if (line.length + token.length > 118 && line.trim().length > 0) {
486
+ lines.push(line.trimEnd());
487
+ line = indent;
488
+ }
489
+ line += token;
490
+ }
491
+ lines.push(line);
492
+ return lines.join(sep);
493
+ }
494
+
495
+ /** Formats `export type <name> = 'a' | 'b' | ...;` wrapped to the 120-char line limit. */
496
+ function formatColumnUnion(name, values) {
497
+ const lines = [];
498
+ let line = `export type ${name} = `;
499
+ for (let i = 0; i < values.length; i++) {
500
+ const token = `'${values[i]}'` + (i < values.length - 1 ? ' | ' : ';');
501
+ if (line.length + token.length > 118) {
502
+ lines.push(line.trimEnd());
503
+ line = ' ';
504
+ }
505
+ line += token;
506
+ }
507
+ lines.push(line);
508
+ return lines.join(sep);
509
+ }
133
510
  function api(args) {
134
511
  color.setVerbose(args.verbose || args.v || false);
135
512
  _package = JSON.parse(_fs.default.readFileSync(packagePath, {
136
513
  encoding: 'utf-8'
137
514
  }));
138
515
  if (_package.friendlyName) _package.friendlyName = _package.friendlyName.replaceAll(' ', '');
139
- const nOptions = Object.keys(args).length - 1 - (args.verbose ? 1 : 0) - (args.v ? 1 : 0);
516
+ const nOptions = Object.keys(args).length - 1 - (args.verbose ? 1 : 0) - (args.v ? 1 : 0) - (args.ui ? 1 : 0);
140
517
  if (args['_'].length !== 1 || nOptions > 0) return false;
141
518
  if (!utils.isPackageDir(process.cwd())) {
142
519
  color.error('File `package.json` not found. Run the command from the package directory');
@@ -146,5 +523,7 @@ function api(args) {
146
523
  generateScriptWrappers();
147
524
  generateQueryWrappers();
148
525
  generateFunctionWrappers();
149
- return true;
526
+ return generateDomainClients(curDir, {
527
+ ui: args.ui ?? false
528
+ });
150
529
  }