@growthub/cli 0.9.11 → 0.9.12

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.
@@ -0,0 +1,433 @@
1
+ function parseCsv(text) {
2
+ const lines = String(text || "").trim().split("\n").filter(Boolean);
3
+ if (!lines.length) return { columns: [], rows: [] };
4
+ const parseLine = (line) => {
5
+ const cells = [];
6
+ let value = "";
7
+ let quoted = false;
8
+ for (let index = 0; index < line.length; index += 1) {
9
+ const char = line[index];
10
+ if (char === '"') {
11
+ if (quoted && line[index + 1] === '"') {
12
+ value += '"';
13
+ index += 1;
14
+ } else {
15
+ quoted = !quoted;
16
+ }
17
+ } else if (char === "," && !quoted) {
18
+ cells.push(value);
19
+ value = "";
20
+ } else {
21
+ value += char;
22
+ }
23
+ }
24
+ cells.push(value);
25
+ return cells;
26
+ };
27
+ const columns = parseLine(lines[0]).map((cell) => cell.trim()).filter(Boolean);
28
+ const rows = lines.slice(1).map((line) => {
29
+ const cells = parseLine(line);
30
+ return columns.reduce((record, column, index) => {
31
+ record[column] = (cells[index] || "").trim();
32
+ return record;
33
+ }, {});
34
+ });
35
+ return { columns, rows };
36
+ }
37
+
38
+ function toCsv(columns, rows) {
39
+ const escape = (value) => {
40
+ const text = String(value ?? "");
41
+ return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
42
+ };
43
+ const header = columns.map(escape).join(",");
44
+ const body = rows.map((row) => columns.map((column) => escape(row?.[column])).join(",")).join("\n");
45
+ return body ? `${header}\n${body}` : header;
46
+ }
47
+
48
+ function parseJsonRows(text) {
49
+ try {
50
+ const parsed = JSON.parse(text || "[]");
51
+ const rows = Array.isArray(parsed) ? parsed.filter((row) => row && typeof row === "object" && !Array.isArray(row)) : [];
52
+ const columns = Array.from(rows.reduce((set, row) => {
53
+ Object.keys(row).forEach((key) => set.add(key));
54
+ return set;
55
+ }, new Set()));
56
+ return { columns, rows };
57
+ } catch {
58
+ return { columns: [], rows: [] };
59
+ }
60
+ }
61
+
62
+ function listWidgetEntries(workspaceConfig) {
63
+ const entries = [];
64
+ const seen = new Set();
65
+ const push = (widget, location) => {
66
+ if (!widget?.id || seen.has(widget.id)) return;
67
+ seen.add(widget.id);
68
+ entries.push({ widget, location });
69
+ };
70
+
71
+ for (const dashboard of workspaceConfig?.dashboards || []) {
72
+ for (const tab of dashboard.tabs || []) {
73
+ for (const widget of tab.widgets || []) {
74
+ push(widget, {
75
+ dashboardId: dashboard.id,
76
+ dashboardName: dashboard.name,
77
+ tabId: tab.id,
78
+ tabName: tab.name,
79
+ widgetId: widget.id
80
+ });
81
+ }
82
+ }
83
+ }
84
+
85
+ const canvas = workspaceConfig?.canvas;
86
+ for (const tab of canvas?.tabs || []) {
87
+ for (const widget of tab.widgets || []) {
88
+ push(widget, { dashboardId: null, dashboardName: null, tabId: tab.id, tabName: tab.name, widgetId: widget.id });
89
+ }
90
+ }
91
+ for (const widget of canvas?.widgets || []) {
92
+ push(widget, { dashboardId: null, dashboardName: null, tabId: null, tabName: canvas.name || "Tab 1", widgetId: widget.id });
93
+ }
94
+
95
+ return entries;
96
+ }
97
+
98
+ function bindingColumns(binding) {
99
+ const fields = Array.isArray(binding?.fields) ? binding.fields : [];
100
+ return Array.from(new Set([...fields, "id", "label", "entityType", "provider", "lane", "status"])).filter(Boolean);
101
+ }
102
+
103
+ function deriveWidgetTable(widget, location) {
104
+ const config = widget.config || {};
105
+ const binding = config.binding && typeof config.binding === "object" && !Array.isArray(config.binding) ? config.binding : null;
106
+
107
+ if (widget.kind === "view") {
108
+ if (binding?.sourceType === "workspace-data-model") return null;
109
+ const source = config.source || widget.title || "Untitled";
110
+ const integration = binding?.mode === "integration";
111
+ const columns = integration ? bindingColumns(binding) : (Array.isArray(config.columns) ? config.columns : []);
112
+ const rows = integration && (binding.entityId || binding.entityLabel)
113
+ ? [{ id: binding.entityId || "", label: binding.entityLabel || binding.entityId || "", entityType: binding.entityType || "", provider: binding.provider || "", lane: binding.lane || "", status: binding.status || "" }]
114
+ : (Array.isArray(config.rows) ? config.rows : []);
115
+ return { source, columns, rows, binding: binding || { mode: "manual", source: "Manual rows" }, mutable: !integration, storage: "view" };
116
+ }
117
+
118
+ if (binding?.mode === "integration") {
119
+ const source = binding.entityLabel || binding.source || widget.title || "Integration reference";
120
+ const rows = binding.entityId || binding.entityLabel
121
+ ? [{ id: binding.entityId || "", label: binding.entityLabel || binding.entityId || "", entityType: binding.entityType || "", provider: binding.provider || "", lane: binding.lane || "", status: binding.status || "" }]
122
+ : [];
123
+ return { source, columns: bindingColumns(binding), rows, binding, mutable: false, storage: "integration" };
124
+ }
125
+
126
+ if (binding?.mode === "json" && typeof binding.json === "string") {
127
+ const parsed = parseJsonRows(binding.json);
128
+ return { source: binding.source || widget.title || "JSON binding", columns: parsed.columns, rows: parsed.rows, binding, mutable: true, storage: "json" };
129
+ }
130
+
131
+ if (binding?.mode === "csv" && typeof binding.csv === "string") {
132
+ const parsed = parseCsv(binding.csv);
133
+ return { source: binding.source || widget.title || "CSV binding", columns: parsed.columns, rows: parsed.rows, binding, mutable: true, storage: "csv" };
134
+ }
135
+
136
+ if (binding?.mode === "manual" && Array.isArray(binding.rows)) {
137
+ const columns = Array.from(binding.rows.reduce((set, row) => {
138
+ Object.keys(row || {}).forEach((key) => set.add(key));
139
+ return set;
140
+ }, new Set()));
141
+ return { source: binding.source || widget.title || "Manual rows", columns, rows: binding.rows, binding, mutable: true, storage: "manual-binding" };
142
+ }
143
+
144
+ if (widget.kind === "chart" && Array.isArray(config.values)) {
145
+ return {
146
+ source: widget.title || "Chart values",
147
+ columns: ["Index", "Value"],
148
+ rows: config.values.map((value, index) => ({ Index: index + 1, Value: value })),
149
+ binding: binding || { mode: "manual", source: "Chart values" },
150
+ mutable: true,
151
+ storage: "chart-values"
152
+ };
153
+ }
154
+
155
+ return null;
156
+ }
157
+
158
+ function tableId(source, columns) {
159
+ return `table:${source}:${columns.join("\0")}`;
160
+ }
161
+
162
+ function normalizeManualObjects(workspaceConfig) {
163
+ return Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
164
+ }
165
+
166
+ function deriveManualObjectTable(object) {
167
+ const columns = Array.isArray(object.columns) ? object.columns.filter(Boolean) : [];
168
+ const rows = Array.isArray(object.rows) ? object.rows.filter((row) => row && typeof row === "object" && !Array.isArray(row)) : [];
169
+ const source = object.source || object.label || object.name || "Manual object";
170
+ return {
171
+ id: `manual-object:${object.id || source}`,
172
+ label: object.label || object.name || source,
173
+ source,
174
+ columns,
175
+ rows,
176
+ binding: object.binding || { mode: "manual", source: "Data Model" },
177
+ mutable: true,
178
+ storage: "manual-object",
179
+ objectId: object.id,
180
+ widgetRefs: [],
181
+ fieldSettings: {
182
+ hidden: Array.isArray(object.fieldSettings?.hidden) ? object.fieldSettings.hidden : [],
183
+ order: Array.isArray(object.fieldSettings?.order) ? object.fieldSettings.order : columns
184
+ }
185
+ };
186
+ }
187
+
188
+ function listWorkspaceDataModelTables(workspaceConfig) {
189
+ const widgetEntries = listWidgetEntries(workspaceConfig);
190
+ const refsByObjectId = widgetEntries.reduce((map, { widget, location }) => {
191
+ const binding = widget?.config?.binding;
192
+ if (binding?.sourceType !== "workspace-data-model" || !binding.objectId) return map;
193
+ const refs = map.get(binding.objectId) || [];
194
+ refs.push({
195
+ ...location,
196
+ widgetTitle: widget.title,
197
+ widgetKind: widget.kind
198
+ });
199
+ map.set(binding.objectId, refs);
200
+ return map;
201
+ }, new Map());
202
+ const manualObjects = normalizeManualObjects(workspaceConfig).map((object) => {
203
+ const table = deriveManualObjectTable(object);
204
+ return { ...table, widgetRefs: refsByObjectId.get(object.id) || [] };
205
+ });
206
+ const widgetTables = widgetEntries
207
+ .map(({ widget, location }) => {
208
+ const table = deriveWidgetTable(widget, location);
209
+ if (!table) return null;
210
+ return {
211
+ id: tableId(table.source, table.columns),
212
+ label: table.source,
213
+ source: table.source,
214
+ columns: table.columns,
215
+ rows: table.rows,
216
+ binding: table.binding,
217
+ mutable: table.mutable,
218
+ storage: table.storage,
219
+ widgetRefs: [{
220
+ ...location,
221
+ widgetTitle: widget.title,
222
+ widgetKind: widget.kind
223
+ }],
224
+ fieldSettings: {
225
+ hidden: Array.isArray(widget.config?.fieldSettings?.hidden) ? widget.config.fieldSettings.hidden : [],
226
+ order: Array.isArray(widget.config?.fieldSettings?.order) ? widget.config.fieldSettings.order : table.columns
227
+ }
228
+ };
229
+ })
230
+ .filter(Boolean);
231
+ return [...manualObjects, ...widgetTables];
232
+ }
233
+
234
+ function writeTableConfig(config, storage, columns, rows) {
235
+ if (storage === "view") {
236
+ const binding = config.binding?.mode === "manual" ? { ...config.binding, rows } : config.binding;
237
+ return { ...config, columns, rows, binding, fieldSettings: { hidden: config.fieldSettings?.hidden || [], order: columns } };
238
+ }
239
+ if (storage === "json") {
240
+ return { ...config, binding: { ...config.binding, json: JSON.stringify(rows, null, 2) } };
241
+ }
242
+ if (storage === "csv") {
243
+ return { ...config, binding: { ...config.binding, csv: toCsv(columns, rows) } };
244
+ }
245
+ if (storage === "manual-binding") {
246
+ return { ...config, binding: { ...config.binding, rows } };
247
+ }
248
+ if (storage === "chart-values") {
249
+ return { ...config, values: rows.map((row) => Number(row.Value)).filter((value) => Number.isFinite(value)) };
250
+ }
251
+ return config;
252
+ }
253
+
254
+ function applyTableMutation(workspaceConfig, table, mutate) {
255
+ if (table.storage === "manual-object") {
256
+ const objects = normalizeManualObjects(workspaceConfig);
257
+ const dataModel = workspaceConfig.dataModel && typeof workspaceConfig.dataModel === "object" && !Array.isArray(workspaceConfig.dataModel)
258
+ ? workspaceConfig.dataModel
259
+ : {};
260
+ return {
261
+ ...workspaceConfig,
262
+ dataModel: {
263
+ ...dataModel,
264
+ objects: objects.map((object) => {
265
+ if (object.id !== table.objectId) return object;
266
+ const current = deriveManualObjectTable(object);
267
+ const next = mutate({ columns: current.columns, rows: current.rows });
268
+ return {
269
+ ...object,
270
+ columns: next.columns,
271
+ rows: next.rows,
272
+ fieldSettings: { ...(object.fieldSettings || {}), order: next.columns }
273
+ };
274
+ })
275
+ }
276
+ };
277
+ }
278
+
279
+ const ids = new Set((table.widgetRefs || []).map((ref) => ref.widgetId));
280
+ const mutateWidgets = (widgets) => (widgets || []).map((widget) => {
281
+ if (!ids.has(widget.id)) return widget;
282
+ const current = deriveWidgetTable(widget, { widgetId: widget.id });
283
+ if (!current?.mutable) return widget;
284
+ const next = mutate({ columns: current.columns, rows: current.rows });
285
+ return { ...widget, config: writeTableConfig(widget.config || {}, current.storage, next.columns, next.rows) };
286
+ });
287
+
288
+ const dashboards = (workspaceConfig.dashboards || []).map((dashboard) => ({
289
+ ...dashboard,
290
+ tabs: (dashboard.tabs || []).map((tab) => ({ ...tab, widgets: mutateWidgets(tab.widgets) }))
291
+ }));
292
+ let canvas = workspaceConfig.canvas ? { ...workspaceConfig.canvas } : {};
293
+ if (Array.isArray(canvas.widgets)) canvas = { ...canvas, widgets: mutateWidgets(canvas.widgets) };
294
+ if (Array.isArray(canvas.tabs)) canvas = { ...canvas, tabs: canvas.tabs.map((tab) => ({ ...tab, widgets: mutateWidgets(tab.widgets) })) };
295
+ return { ...workspaceConfig, dashboards, canvas };
296
+ }
297
+
298
+ function slugifyObjectName(name) {
299
+ return String(name || "")
300
+ .trim()
301
+ .toLowerCase()
302
+ .replace(/[^a-z0-9]+/g, "-")
303
+ .replace(/^-+|-+$/g, "")
304
+ || "object";
305
+ }
306
+
307
+ function uniqueObjectId(workspaceConfig, name) {
308
+ const base = slugifyObjectName(name);
309
+ const used = new Set(normalizeManualObjects(workspaceConfig).map((object) => object.id));
310
+ if (!used.has(base)) return base;
311
+ let index = 2;
312
+ while (used.has(`${base}-${index}`)) index += 1;
313
+ return `${base}-${index}`;
314
+ }
315
+
316
+ function createManualBusinessObject(workspaceConfig, { name, fields } = {}) {
317
+ const label = String(name || "").trim();
318
+ const columns = Array.from(new Set((Array.isArray(fields) ? fields : String(fields || "").split(","))
319
+ .map((field) => String(field || "").trim())
320
+ .filter(Boolean)));
321
+ if (!label || !columns.length) return workspaceConfig;
322
+ const dataModel = workspaceConfig.dataModel && typeof workspaceConfig.dataModel === "object" && !Array.isArray(workspaceConfig.dataModel)
323
+ ? workspaceConfig.dataModel
324
+ : {};
325
+ const id = uniqueObjectId(workspaceConfig, label);
326
+ const object = {
327
+ id,
328
+ label,
329
+ source: label,
330
+ columns,
331
+ rows: [],
332
+ binding: { mode: "manual", source: "Data Model" },
333
+ fieldSettings: { hidden: [], order: columns }
334
+ };
335
+ return {
336
+ ...workspaceConfig,
337
+ dataModel: {
338
+ ...dataModel,
339
+ objects: [...normalizeManualObjects(workspaceConfig), object]
340
+ }
341
+ };
342
+ }
343
+
344
+ function addTableField(workspaceConfig, table, fieldName) {
345
+ const name = String(fieldName || "").trim();
346
+ if (!name || !table.mutable) return workspaceConfig;
347
+ return applyTableMutation(workspaceConfig, table, ({ columns, rows }) => {
348
+ if (columns.includes(name)) return { columns, rows };
349
+ return { columns: [...columns, name], rows: rows.map((row) => ({ ...row, [name]: "" })) };
350
+ });
351
+ }
352
+
353
+ function addTableRow(workspaceConfig, table) {
354
+ if (!table.mutable) return workspaceConfig;
355
+ return applyTableMutation(workspaceConfig, table, ({ columns, rows }) => ({
356
+ columns,
357
+ rows: [...rows, Object.fromEntries(columns.map((column) => [column, ""]))]
358
+ }));
359
+ }
360
+
361
+ function updateTableCell(workspaceConfig, table, rowIndex, fieldName, value) {
362
+ if (!table.mutable) return workspaceConfig;
363
+ return applyTableMutation(workspaceConfig, table, ({ columns, rows }) => ({
364
+ columns,
365
+ rows: rows.map((row, index) => index === rowIndex ? { ...row, [fieldName]: value } : row)
366
+ }));
367
+ }
368
+
369
+ function deleteTableRow(workspaceConfig, table, rowIndex) {
370
+ if (!table.mutable) return workspaceConfig;
371
+ return applyTableMutation(workspaceConfig, table, ({ columns, rows }) => ({
372
+ columns,
373
+ rows: rows.filter((_, index) => index !== rowIndex)
374
+ }));
375
+ }
376
+
377
+ function duplicateTableRow(workspaceConfig, table, rowIndex) {
378
+ if (!table.mutable) return workspaceConfig;
379
+ return applyTableMutation(workspaceConfig, table, ({ columns, rows }) => {
380
+ const next = [...rows];
381
+ if (rows[rowIndex]) next.splice(rowIndex + 1, 0, { ...rows[rowIndex] });
382
+ return { columns, rows: next };
383
+ });
384
+ }
385
+
386
+ function appendRowsToTable(workspaceConfig, table, rowsToAppend) {
387
+ if (!table.mutable || !Array.isArray(rowsToAppend)) return workspaceConfig;
388
+ return applyTableMutation(workspaceConfig, table, ({ columns, rows }) => ({ columns, rows: [...rows, ...rowsToAppend] }));
389
+ }
390
+
391
+ function replaceTableContent(workspaceConfig, table, { columns = [], rows = [] } = {}) {
392
+ if (!table.mutable) return workspaceConfig;
393
+ return applyTableMutation(workspaceConfig, table, () => ({ columns, rows }));
394
+ }
395
+
396
+ function exportTableAsCsv(table) {
397
+ return toCsv(table.columns || [], table.rows || []);
398
+ }
399
+
400
+ function importTableFromCsv(text) {
401
+ return parseCsv(text);
402
+ }
403
+
404
+ function describeBindingLane(binding) {
405
+ if (binding?.mode === "integration" && binding.lane === "data-source") return "data-source";
406
+ if (binding?.mode === "integration" && binding.lane === "workspace-integration") return "workspace-integration";
407
+ if (binding?.mode === "integration") return "integration";
408
+ return "manual";
409
+ }
410
+
411
+ function describeBindingMode(binding) {
412
+ const lane = describeBindingLane(binding);
413
+ if (lane === "data-source") return { label: "Data source scope", description: "Integration reference selected in the existing widget source flow. Dynamic data resolves through the governed server-side integration path." };
414
+ if (lane === "workspace-integration") return { label: "Workspace tool scope", description: "Workspace integration reference selected in the existing widget source flow." };
415
+ if (lane === "integration") return { label: "Integration scope", description: "Integration reference stored on widget.config.binding." };
416
+ return { label: "Manual local table", description: "Rows and fields live in the existing widget config and travel with workspace export/import." };
417
+ }
418
+
419
+ export {
420
+ addTableField,
421
+ addTableRow,
422
+ appendRowsToTable,
423
+ createManualBusinessObject,
424
+ deleteTableRow,
425
+ describeBindingLane,
426
+ describeBindingMode,
427
+ duplicateTableRow,
428
+ exportTableAsCsv,
429
+ importTableFromCsv,
430
+ listWorkspaceDataModelTables,
431
+ replaceTableContent,
432
+ updateTableCell
433
+ };
@@ -12,11 +12,12 @@
12
12
  * - `widgetTypes` palette of allowed widget kinds (label/icon)
13
13
  * - `canvas` active canvas: layout, single-tab `widgets[]`, or
14
14
  * multi-tab `tabs[]` + `activeTabId`, plus `bindings`
15
+ * - `dataModel` governed manual data objects, never dashboard widgets
15
16
  *
16
17
  * Other top-level fields (`id`, `name`, `description`, `capabilities`,
17
18
  * `branding`, `pipelines`, `integrations`, `provenance`) are preserved
18
19
  * round-trip but cannot be mutated through PATCH. The validator rejects
19
- * unknown fields inside the three allowlisted sections.
20
+ * unknown fields inside the allowlisted sections.
20
21
  *
21
22
  * Canonical canvas shape (mutually exclusive — never both at once):
22
23
  *
@@ -39,7 +40,7 @@
39
40
  const GRID_COLUMNS = 12;
40
41
  const GRID_ROWS = 16;
41
42
  const KNOWN_WIDGET_KINDS = ["chart", "view", "iframe", "rich-text"];
42
- const KNOWN_FIELDS = ["dashboards", "widgetTypes", "canvas"];
43
+ const KNOWN_FIELDS = ["dashboards", "widgetTypes", "canvas", "dataModel"];
43
44
  const KNOWN_DATA_BINDING_MODES = ["manual", "json", "csv", "integration"];
44
45
  const KNOWN_CHART_TYPES = ["bar-vertical", "bar-horizontal", "line", "pie", "sum", "gauge"];
45
46
  const KNOWN_FILTER_OPERATORS = ["eq", "ne", "contains", "gt", "lt", "isEmpty", "isNotEmpty"];
@@ -202,11 +203,11 @@ function defaultConfigFor(kind) {
202
203
  return { values: [58, 36, 72, 48, 64], binding: SAMPLE_DATA_BINDINGS.reportingJson };
203
204
  case "view":
204
205
  return {
205
- source: "Companies",
206
+ source: "",
206
207
  layout: "Table",
207
- columns: ["Name", "Domain Name"],
208
- rows: SAMPLE_VIEW_ROWS,
209
- binding: SAMPLE_DATA_BINDINGS.companiesManual
208
+ columns: [],
209
+ rows: [],
210
+ binding: { mode: "manual", source: "Static rows", rows: [] }
210
211
  };
211
212
  case "iframe":
212
213
  return { url: "" };
@@ -247,7 +248,13 @@ const DASHBOARD_TEMPLATES = [
247
248
  dashboard: { name: "Client Portal", status: "draft" },
248
249
  widgets: [
249
250
  createWidget("rich-text", "Client Summary", { x: 0, y: 0, w: 4, h: 4 }, { text: "Current client priorities, owner notes, and next milestone.", binding: { mode: "manual", source: "Manual text", rows: [] } }),
250
- createWidget("view", "Companies", { x: 4, y: 0, w: 5, h: 5 }),
251
+ createWidget("view", "Companies", { x: 4, y: 0, w: 5, h: 5 }, {
252
+ source: "Companies",
253
+ layout: "Table",
254
+ columns: ["Name", "Domain Name"],
255
+ rows: SAMPLE_VIEW_ROWS,
256
+ binding: SAMPLE_DATA_BINDINGS.companiesManual
257
+ }),
251
258
  createWidget("iframe", "Client Portal Embed", { x: 9, y: 0, w: 3, h: 5 }, { url: "" }),
252
259
  createWidget("chart", "Delivery Health", { x: 0, y: 4, w: 4, h: 4 }, { values: [72, 64, 81, 58, 76], binding: SAMPLE_DATA_BINDINGS.reportingJson })
253
260
  ]
@@ -414,6 +421,9 @@ function validateStaticDataBinding(binding, path, errors) {
414
421
  if (binding.endpointRef !== undefined && typeof binding.endpointRef !== "string") {
415
422
  errors.push(`${path}.endpointRef must be a string`);
416
423
  }
424
+ if (binding.objectId !== undefined && typeof binding.objectId !== "string") {
425
+ errors.push(`${path}.objectId must be a string`);
426
+ }
417
427
  if (binding.integrationId !== undefined && typeof binding.integrationId !== "string") {
418
428
  errors.push(`${path}.integrationId must be a string`);
419
429
  }
@@ -768,6 +778,46 @@ function validateCanvasConfig(canvas, errors) {
768
778
  }
769
779
  }
770
780
 
781
+ function validateDataModelConfig(dataModel, errors) {
782
+ if (dataModel === undefined) return;
783
+ if (!isPlainObject(dataModel)) {
784
+ errors.push("dataModel must be a plain object");
785
+ return;
786
+ }
787
+ if (dataModel.objects === undefined) return;
788
+ if (!Array.isArray(dataModel.objects)) {
789
+ errors.push("dataModel.objects must be an array");
790
+ return;
791
+ }
792
+ const ids = new Set();
793
+ dataModel.objects.forEach((object, index) => {
794
+ const prefix = `dataModel.objects[${index}]`;
795
+ if (!isPlainObject(object)) {
796
+ errors.push(`${prefix} must be an object`);
797
+ return;
798
+ }
799
+ if (typeof object.id !== "string" || !object.id.trim()) {
800
+ errors.push(`${prefix}.id must be a non-empty string`);
801
+ } else if (ids.has(object.id)) {
802
+ errors.push(`${prefix}.id duplicates an earlier object id`);
803
+ } else {
804
+ ids.add(object.id);
805
+ }
806
+ if (typeof object.label !== "string" || !object.label.trim()) errors.push(`${prefix}.label must be a non-empty string`);
807
+ if (object.source !== undefined && typeof object.source !== "string") errors.push(`${prefix}.source must be a string`);
808
+ validateStringArray(object.columns, `${prefix}.columns`, errors);
809
+ if (!Array.isArray(object.rows)) {
810
+ errors.push(`${prefix}.rows must be an array`);
811
+ } else {
812
+ object.rows.forEach((row, rowIndex) => {
813
+ if (!isPlainObject(row)) errors.push(`${prefix}.rows[${rowIndex}] must be a plain object`);
814
+ });
815
+ }
816
+ validateStaticDataBinding(object.binding, `${prefix}.binding`, errors);
817
+ validateFieldSettings(object.fieldSettings, `${prefix}.fieldSettings`, errors);
818
+ });
819
+ }
820
+
771
821
  function validateTemplateWidgetArray(widgets, contextPath, errors) {
772
822
  if (!Array.isArray(widgets)) {
773
823
  errors.push(`${contextPath} must be an array`);
@@ -996,6 +1046,7 @@ function validateWorkspaceConfig(nextConfig) {
996
1046
  if (nextConfig.dashboards !== undefined) validateDashboardArray(nextConfig.dashboards, errors);
997
1047
  if (nextConfig.widgetTypes !== undefined && !Array.isArray(nextConfig.widgetTypes)) errors.push("widgetTypes must be an array");
998
1048
  if (nextConfig.canvas !== undefined) validateCanvasConfig(nextConfig.canvas, errors);
1049
+ if (nextConfig.dataModel !== undefined) validateDataModelConfig(nextConfig.dataModel, errors);
999
1050
  if (errors.length) {
1000
1051
  const error = new Error(`invalid workspace config: ${errors.join("; ")}`);
1001
1052
  error.code = "INVALID_WORKSPACE_CONFIG";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growthub/cli",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Growthub Local is a control plane for forked worker kits. The CLI is the executor, the hosted app is the identity authority, the worker kit is the unit of portable agent infrastructure, and the fork is the operator's personal branch of that infrastructure — policy-governed, trace-backed, and self-healing.",
5
5
  "type": "module",
6
6
  "bin": {