@growthub/cli 0.9.4 → 0.9.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.
@@ -0,0 +1,112 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { readAdapterConfig } from "@/lib/adapters/env";
4
+ import {
5
+ GRID_COLUMNS,
6
+ GRID_ROWS,
7
+ KNOWN_WIDGET_KINDS,
8
+ validateWorkspaceConfig
9
+ } from "@/lib/workspace-schema";
10
+
11
+ function resolveWorkspaceConfigPath() {
12
+ return path.resolve(/*turbopackIgnore: true*/ process.cwd(), "growthub.config.json");
13
+ }
14
+
15
+ async function readWorkspaceConfig() {
16
+ const configPath = resolveWorkspaceConfigPath();
17
+ const raw = await fs.readFile(configPath, "utf8");
18
+ return JSON.parse(raw);
19
+ }
20
+
21
+ function describePersistenceMode() {
22
+ const target = process.env.AGENCY_PORTAL_DEPLOY_TARGET || "vercel";
23
+ const isReadOnlyDeploy = target === "vercel" || target === "netlify";
24
+ const allowFsWrite = process.env.WORKSPACE_CONFIG_ALLOW_FS_WRITE === "true";
25
+ if (allowFsWrite) {
26
+ return { mode: "filesystem", reason: "WORKSPACE_CONFIG_ALLOW_FS_WRITE=true" };
27
+ }
28
+ if (process.env.NODE_ENV === "development") {
29
+ return { mode: "filesystem", reason: "Local Next.js development" };
30
+ }
31
+ if (isReadOnlyDeploy) {
32
+ return {
33
+ mode: "read-only",
34
+ reason: `Deploy target ${target} treats the bundle as read-only. Set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true on a writable runtime, or wire a hosted persistence adapter.`
35
+ };
36
+ }
37
+ return { mode: "filesystem", reason: "Local development" };
38
+ }
39
+
40
+ function applyPatch(currentConfig, patch) {
41
+ const next = { ...currentConfig };
42
+ if (patch.dashboards !== undefined) next.dashboards = patch.dashboards;
43
+ if (patch.widgetTypes !== undefined) next.widgetTypes = patch.widgetTypes;
44
+ if (patch.canvas !== undefined && patch.canvas !== null) {
45
+ const patchCanvas = { ...patch.canvas };
46
+ if (Array.isArray(patchCanvas.tabs)) {
47
+ delete patchCanvas.widgets;
48
+ delete patchCanvas.name;
49
+ } else if (Array.isArray(patchCanvas.widgets)) {
50
+ delete patchCanvas.tabs;
51
+ delete patchCanvas.activeTabId;
52
+ }
53
+ next.canvas = {
54
+ ...currentConfig.canvas,
55
+ ...patchCanvas,
56
+ layout: { ...(currentConfig.canvas?.layout || {}), ...(patchCanvas.layout || {}) },
57
+ bindings: { ...(currentConfig.canvas?.bindings || {}), ...(patchCanvas.bindings || {}) }
58
+ };
59
+ if (Array.isArray(patch.canvas.tabs)) {
60
+ delete next.canvas.widgets;
61
+ delete next.canvas.name;
62
+ }
63
+ if (Array.isArray(patch.canvas.widgets)) {
64
+ delete next.canvas.tabs;
65
+ delete next.canvas.activeTabId;
66
+ }
67
+ for (const key of ["widgets", "tabs", "activeTabId", "name"]) {
68
+ if (Object.prototype.hasOwnProperty.call(patchCanvas, key) && patchCanvas[key] === null) {
69
+ delete next.canvas[key];
70
+ }
71
+ }
72
+ }
73
+ return next;
74
+ }
75
+
76
+ async function writeWorkspaceConfig(patch) {
77
+ const persistence = describePersistenceMode();
78
+ const adapter = readAdapterConfig();
79
+ if (persistence.mode !== "filesystem") {
80
+ const error = new Error(persistence.reason);
81
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
82
+ error.adapter = adapter.integrationAdapter;
83
+ throw error;
84
+ }
85
+ const current = await readWorkspaceConfig();
86
+ const next = applyPatch(current, patch);
87
+ validateWorkspaceConfig({
88
+ dashboards: next.dashboards,
89
+ widgetTypes: next.widgetTypes,
90
+ canvas: next.canvas
91
+ });
92
+ const configPath = resolveWorkspaceConfigPath();
93
+ const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
94
+ if (path.dirname(configPath) !== expectedDir) {
95
+ const error = new Error(`refused to write outside workspace cwd: ${configPath}`);
96
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
97
+ throw error;
98
+ }
99
+ await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
100
+ return next;
101
+ }
102
+
103
+ export {
104
+ GRID_COLUMNS,
105
+ GRID_ROWS,
106
+ KNOWN_WIDGET_KINDS,
107
+ describePersistenceMode,
108
+ readWorkspaceConfig,
109
+ resolveWorkspaceConfigPath,
110
+ validateWorkspaceConfig,
111
+ writeWorkspaceConfig
112
+ };
@@ -0,0 +1,461 @@
1
+ const GRID_COLUMNS = 12;
2
+ const GRID_ROWS = 16;
3
+ const KNOWN_WIDGET_KINDS = ["chart", "view", "iframe", "rich-text"];
4
+ const KNOWN_FIELDS = ["dashboards", "widgetTypes", "canvas"];
5
+ const KNOWN_DATA_BINDING_MODES = ["manual", "json", "csv"];
6
+
7
+ const WIDGET_SCHEMA_CONTRACTS = {
8
+ WidgetPosition: {
9
+ x: "integer >= 0",
10
+ y: "integer >= 0",
11
+ w: "integer >= 1",
12
+ h: "integer >= 1",
13
+ invariant: `x + w <= ${GRID_COLUMNS}; y + h <= ${GRID_ROWS}; no cell overlap`
14
+ },
15
+ WidgetBase: {
16
+ id: "non-empty string",
17
+ kind: KNOWN_WIDGET_KINDS.join(" | "),
18
+ title: "non-empty string",
19
+ position: "WidgetPosition",
20
+ config: "kind-specific config object"
21
+ },
22
+ ChartWidgetConfig: {
23
+ values: "number[]",
24
+ binding: "StaticDataBinding optional"
25
+ },
26
+ ViewWidgetConfig: {
27
+ source: "string",
28
+ layout: "Table",
29
+ columns: "string[]",
30
+ rows: "record[]",
31
+ binding: "StaticDataBinding optional"
32
+ },
33
+ IframeWidgetConfig: {
34
+ url: "string"
35
+ },
36
+ RichTextWidgetConfig: {
37
+ text: "string",
38
+ binding: "StaticDataBinding optional"
39
+ },
40
+ DashboardConfig: {
41
+ id: "non-empty string",
42
+ name: "non-empty string",
43
+ createdBy: "string",
44
+ updatedAt: "string",
45
+ status: "draft | active | archived"
46
+ },
47
+ CanvasConfig: {
48
+ layout: `{ columns: ${GRID_COLUMNS}, rowHeight: number, gap: number, responsive: boolean }`,
49
+ widgets: "WidgetBase[] for single-tab canvases only",
50
+ tabs: "optional tab array for multi-tab canvases; each tab owns WidgetBase[] and replaces canvas.widgets",
51
+ activeTabId: "optional active tab id",
52
+ bindings: "workspace-level boolean/config bindings"
53
+ },
54
+ StaticDataBinding: {
55
+ mode: KNOWN_DATA_BINDING_MODES.join(" | "),
56
+ source: "string",
57
+ rows: "manual record[] optional",
58
+ json: "JSON string optional",
59
+ csv: "CSV string optional"
60
+ }
61
+ };
62
+
63
+ const SAMPLE_VIEW_ROWS = [
64
+ { Name: "CMWL Direct", "Domain Name": "centerformedica" },
65
+ { Name: "Medi-Weightloss", "Domain Name": "mediweightloss.com" },
66
+ { Name: "Optima Tyler", "Domain Name": "optimatyler.com" },
67
+ { Name: "Balanced Hormone He...", "Domain Name": "balancedhormor" },
68
+ { Name: "Jolie Aesthetics RVA", "Domain Name": "jolie-aesthetics.c" },
69
+ { Name: "Livea Centers", "Domain Name": "livea.com" }
70
+ ];
71
+
72
+ const SAMPLE_DATA_BINDINGS = {
73
+ companiesManual: {
74
+ mode: "manual",
75
+ source: "Manual rows",
76
+ rows: SAMPLE_VIEW_ROWS
77
+ },
78
+ reportingJson: {
79
+ mode: "json",
80
+ source: "Sample JSON",
81
+ json: JSON.stringify([
82
+ { metric: "Leads", value: 42 },
83
+ { metric: "Qualified", value: 18 },
84
+ { metric: "Booked", value: 7 }
85
+ ], null, 2)
86
+ },
87
+ contentCsv: {
88
+ mode: "csv",
89
+ source: "Sample CSV",
90
+ csv: "channel,status,count\nBlog,Draft,4\nEmail,Review,3\nSocial,Scheduled,9"
91
+ }
92
+ };
93
+
94
+ function defaultConfigFor(kind) {
95
+ switch (kind) {
96
+ case "chart":
97
+ return { values: [58, 36, 72, 48, 64], binding: SAMPLE_DATA_BINDINGS.reportingJson };
98
+ case "view":
99
+ return {
100
+ source: "Companies",
101
+ layout: "Table",
102
+ columns: ["Name", "Domain Name"],
103
+ rows: SAMPLE_VIEW_ROWS,
104
+ binding: SAMPLE_DATA_BINDINGS.companiesManual
105
+ };
106
+ case "iframe":
107
+ return { url: "" };
108
+ case "rich-text":
109
+ return { text: "", binding: { mode: "manual", source: "Manual text", rows: [] } };
110
+ default:
111
+ return {};
112
+ }
113
+ }
114
+
115
+ function createWidget(kind, title, position, config = defaultConfigFor(kind)) {
116
+ return { kind, title, position, config };
117
+ }
118
+
119
+ const DASHBOARD_TEMPLATES = [
120
+ {
121
+ id: "blank",
122
+ name: "Blank",
123
+ description: "Empty governed canvas",
124
+ widgets: []
125
+ },
126
+ {
127
+ id: "client-portal",
128
+ name: "Client Portal",
129
+ description: "Client status, documents, and embedded portal area",
130
+ widgets: [
131
+ 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: [] } }),
132
+ createWidget("view", "Companies", { x: 4, y: 0, w: 5, h: 5 }),
133
+ createWidget("iframe", "Client Portal Embed", { x: 9, y: 0, w: 3, h: 5 }, { url: "" }),
134
+ createWidget("chart", "Delivery Health", { x: 0, y: 4, w: 4, h: 4 }, { values: [72, 64, 81, 58, 76], binding: SAMPLE_DATA_BINDINGS.reportingJson })
135
+ ]
136
+ },
137
+ {
138
+ id: "content-ops",
139
+ name: "Content Ops",
140
+ description: "Editorial pipeline and review snapshot",
141
+ widgets: [
142
+ createWidget("view", "Content Queue", { x: 0, y: 0, w: 5, h: 5 }, {
143
+ source: "Content",
144
+ layout: "Table",
145
+ columns: ["Channel", "Status"],
146
+ rows: [
147
+ { Channel: "Blog", Status: "Draft" },
148
+ { Channel: "Email", Status: "Review" },
149
+ { Channel: "Social", Status: "Scheduled" }
150
+ ],
151
+ binding: SAMPLE_DATA_BINDINGS.contentCsv
152
+ }),
153
+ createWidget("chart", "Publishing Mix", { x: 5, y: 0, w: 4, h: 4 }, { values: [34, 52, 45, 61, 38], binding: SAMPLE_DATA_BINDINGS.contentCsv }),
154
+ createWidget("rich-text", "Review Notes", { x: 9, y: 0, w: 3, h: 4 }, { text: "Open creative review notes and approval blockers.", binding: { mode: "manual", source: "Manual text", rows: [] } })
155
+ ]
156
+ },
157
+ {
158
+ id: "reporting-dashboard",
159
+ name: "Reporting Dashboard",
160
+ description: "KPIs, table, and executive readout",
161
+ widgets: [
162
+ createWidget("chart", "Pipeline Trend", { x: 0, y: 0, w: 4, h: 5 }, { values: [42, 58, 63, 71, 86], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
163
+ createWidget("chart", "Conversion", { x: 4, y: 0, w: 4, h: 5 }, { values: [28, 36, 44, 39, 52], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
164
+ createWidget("view", "Performance Table", { x: 8, y: 0, w: 4, h: 5 }),
165
+ createWidget("rich-text", "Executive Summary", { x: 0, y: 5, w: 6, h: 3 }, { text: "Weekly readout, risks, and decisions.", binding: { mode: "manual", source: "Manual text", rows: [] } })
166
+ ]
167
+ },
168
+ {
169
+ id: "creative-review",
170
+ name: "Creative Review",
171
+ description: "Creative artifact embed and approval notes",
172
+ widgets: [
173
+ createWidget("iframe", "Creative Preview", { x: 0, y: 0, w: 7, h: 6 }, { url: "" }),
174
+ createWidget("rich-text", "Approval Notes", { x: 7, y: 0, w: 5, h: 3 }, { text: "Feedback, approvals, and revision requests.", binding: { mode: "manual", source: "Manual text", rows: [] } }),
175
+ createWidget("view", "Review Queue", { x: 7, y: 3, w: 5, h: 4 }, {
176
+ source: "Creative",
177
+ layout: "Table",
178
+ columns: ["Asset", "Status"],
179
+ rows: [
180
+ { Asset: "Landing Page", Status: "Review" },
181
+ { Asset: "Email Hero", Status: "Approved" },
182
+ { Asset: "Social Set", Status: "Revision" }
183
+ ],
184
+ binding: { mode: "manual", source: "Manual rows", rows: [] }
185
+ })
186
+ ]
187
+ },
188
+ {
189
+ id: "agency-delivery",
190
+ name: "Agency Delivery",
191
+ description: "Agency workstream, KPI, and delivery notes",
192
+ widgets: [
193
+ createWidget("view", "Delivery Board", { x: 0, y: 0, w: 5, h: 5 }, {
194
+ source: "Tasks",
195
+ layout: "Table",
196
+ columns: ["Workstream", "Owner"],
197
+ rows: [
198
+ { Workstream: "Strategy", Owner: "Agency" },
199
+ { Workstream: "Creative", Owner: "Design" },
200
+ { Workstream: "Launch", Owner: "Ops" }
201
+ ],
202
+ binding: { mode: "manual", source: "Manual rows", rows: [] }
203
+ }),
204
+ createWidget("chart", "Utilization", { x: 5, y: 0, w: 3, h: 4 }, { values: [62, 74, 69, 82, 77], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
205
+ createWidget("rich-text", "Client Commitments", { x: 8, y: 0, w: 4, h: 4 }, { text: "Committed scope, launch date, and open risks.", binding: { mode: "manual", source: "Manual text", rows: [] } }),
206
+ createWidget("iframe", "Delivery Portal", { x: 0, y: 5, w: 6, h: 4 }, { url: "" })
207
+ ]
208
+ }
209
+ ];
210
+
211
+ function isPlainObject(value) {
212
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
213
+ }
214
+
215
+ function isFiniteInt(value) {
216
+ return typeof value === "number" && Number.isFinite(value) && Math.floor(value) === value;
217
+ }
218
+
219
+ function validateStringArray(value, path, errors) {
220
+ if (!Array.isArray(value)) {
221
+ errors.push(`${path} must be an array`);
222
+ return;
223
+ }
224
+ value.forEach((item, index) => {
225
+ if (typeof item !== "string") errors.push(`${path}[${index}] must be a string`);
226
+ });
227
+ }
228
+
229
+ function validateStaticDataBinding(binding, path, errors) {
230
+ if (binding === undefined) return;
231
+ if (!isPlainObject(binding)) {
232
+ errors.push(`${path} must be a plain object`);
233
+ return;
234
+ }
235
+ if (!KNOWN_DATA_BINDING_MODES.includes(binding.mode)) {
236
+ errors.push(`${path}.mode must be one of ${KNOWN_DATA_BINDING_MODES.join(", ")}`);
237
+ }
238
+ if (binding.source !== undefined && typeof binding.source !== "string") {
239
+ errors.push(`${path}.source must be a string`);
240
+ }
241
+ if (binding.rows !== undefined && !Array.isArray(binding.rows)) {
242
+ errors.push(`${path}.rows must be an array`);
243
+ }
244
+ if (binding.json !== undefined && typeof binding.json !== "string") {
245
+ errors.push(`${path}.json must be a string`);
246
+ }
247
+ if (binding.csv !== undefined && typeof binding.csv !== "string") {
248
+ errors.push(`${path}.csv must be a string`);
249
+ }
250
+ }
251
+
252
+ function validateWidgetConfig(kind, config, path, errors) {
253
+ if (config === undefined) return;
254
+ if (!isPlainObject(config)) {
255
+ errors.push(`${path} must be a plain object`);
256
+ return;
257
+ }
258
+ if (kind === "chart") {
259
+ if (config.values !== undefined) {
260
+ if (!Array.isArray(config.values)) {
261
+ errors.push(`${path}.values must be an array`);
262
+ } else {
263
+ config.values.forEach((value, index) => {
264
+ if (typeof value !== "number" || !Number.isFinite(value)) {
265
+ errors.push(`${path}.values[${index}] must be a finite number`);
266
+ }
267
+ });
268
+ }
269
+ }
270
+ validateStaticDataBinding(config.binding, `${path}.binding`, errors);
271
+ }
272
+ if (kind === "view") {
273
+ if (config.source !== undefined && typeof config.source !== "string") errors.push(`${path}.source must be a string`);
274
+ if (config.layout !== undefined && config.layout !== "Table") errors.push(`${path}.layout must be Table`);
275
+ if (config.columns !== undefined) validateStringArray(config.columns, `${path}.columns`, errors);
276
+ if (config.rows !== undefined && !Array.isArray(config.rows)) errors.push(`${path}.rows must be an array`);
277
+ validateStaticDataBinding(config.binding, `${path}.binding`, errors);
278
+ }
279
+ if (kind === "iframe" && config.url !== undefined && typeof config.url !== "string") {
280
+ errors.push(`${path}.url must be a string`);
281
+ }
282
+ if (kind === "rich-text") {
283
+ if (config.text !== undefined && typeof config.text !== "string") errors.push(`${path}.text must be a string`);
284
+ validateStaticDataBinding(config.binding, `${path}.binding`, errors);
285
+ }
286
+ }
287
+
288
+ function validateDashboardArray(dashboards, errors) {
289
+ if (!Array.isArray(dashboards)) {
290
+ errors.push("dashboards must be an array");
291
+ return;
292
+ }
293
+ const ids = new Set();
294
+ dashboards.forEach((dashboard, index) => {
295
+ const prefix = `dashboards[${index}]`;
296
+ if (!isPlainObject(dashboard)) {
297
+ errors.push(`${prefix} must be an object`);
298
+ return;
299
+ }
300
+ if (typeof dashboard.id !== "string" || !dashboard.id) errors.push(`${prefix}.id must be a non-empty string`);
301
+ if (dashboard.id && ids.has(dashboard.id)) errors.push(`${prefix}.id duplicates an earlier dashboard id`);
302
+ ids.add(dashboard.id);
303
+ if (typeof dashboard.name !== "string" || !dashboard.name) errors.push(`${prefix}.name must be a non-empty string`);
304
+ if (dashboard.createdBy !== undefined && typeof dashboard.createdBy !== "string") errors.push(`${prefix}.createdBy must be a string`);
305
+ if (dashboard.updatedAt !== undefined && typeof dashboard.updatedAt !== "string") errors.push(`${prefix}.updatedAt must be a string`);
306
+ if (dashboard.status !== undefined && !["draft", "active", "archived"].includes(dashboard.status)) {
307
+ errors.push(`${prefix}.status must be draft, active, or archived`);
308
+ }
309
+ });
310
+ }
311
+
312
+ function validateWidgetArray(widgets, contextPath, errors, seenIds) {
313
+ if (!Array.isArray(widgets)) {
314
+ errors.push(`${contextPath} must be an array`);
315
+ return;
316
+ }
317
+ const occupied = new Map();
318
+ widgets.forEach((widget, index) => {
319
+ const prefix = `${contextPath}[${index}]`;
320
+ if (!isPlainObject(widget)) {
321
+ errors.push(`${prefix} must be an object`);
322
+ return;
323
+ }
324
+ if (typeof widget.id !== "string" || !widget.id) {
325
+ errors.push(`${prefix}.id must be a non-empty string`);
326
+ } else if (seenIds.has(widget.id)) {
327
+ errors.push(`${prefix}.id duplicates an earlier widget id`);
328
+ } else {
329
+ seenIds.add(widget.id);
330
+ }
331
+ if (!KNOWN_WIDGET_KINDS.includes(widget.kind)) {
332
+ errors.push(`${prefix}.kind must be one of ${KNOWN_WIDGET_KINDS.join(", ")}`);
333
+ }
334
+ if (typeof widget.title !== "string" || !widget.title) {
335
+ errors.push(`${prefix}.title must be a non-empty string`);
336
+ }
337
+ if (!isPlainObject(widget.position)) {
338
+ errors.push(`${prefix}.position must be an object`);
339
+ return;
340
+ }
341
+ for (const k of ["x", "y", "w", "h"]) {
342
+ if (!isFiniteInt(widget.position[k])) errors.push(`${prefix}.position.${k} must be a finite integer`);
343
+ }
344
+ if (
345
+ isFiniteInt(widget.position.x) &&
346
+ isFiniteInt(widget.position.w) &&
347
+ (widget.position.x < 0 || widget.position.w < 1 || widget.position.x + widget.position.w > GRID_COLUMNS)
348
+ ) {
349
+ errors.push(`${prefix} x/w out of [0..${GRID_COLUMNS}] grid`);
350
+ }
351
+ if (
352
+ isFiniteInt(widget.position.y) &&
353
+ isFiniteInt(widget.position.h) &&
354
+ (widget.position.y < 0 || widget.position.h < 1 || widget.position.y + widget.position.h > GRID_ROWS)
355
+ ) {
356
+ errors.push(`${prefix} y/h out of [0..${GRID_ROWS}] grid`);
357
+ }
358
+ if (
359
+ isFiniteInt(widget.position.x) &&
360
+ isFiniteInt(widget.position.y) &&
361
+ isFiniteInt(widget.position.w) &&
362
+ isFiniteInt(widget.position.h)
363
+ ) {
364
+ for (let dx = 0; dx < widget.position.w; dx += 1) {
365
+ for (let dy = 0; dy < widget.position.h; dy += 1) {
366
+ const cell = `${widget.position.x + dx}:${widget.position.y + dy}`;
367
+ const previous = occupied.get(cell);
368
+ if (previous) {
369
+ errors.push(`${prefix} overlaps ${previous} at grid cell ${cell}`);
370
+ } else {
371
+ occupied.set(cell, `${prefix}.position`);
372
+ }
373
+ }
374
+ }
375
+ }
376
+ validateWidgetConfig(widget.kind, widget.config, `${prefix}.config`, errors);
377
+ });
378
+ }
379
+
380
+ function validateCanvasConfig(canvas, errors) {
381
+ if (!isPlainObject(canvas)) {
382
+ errors.push("canvas must be a plain object");
383
+ return;
384
+ }
385
+ if (canvas.layout !== undefined) {
386
+ if (!isPlainObject(canvas.layout)) {
387
+ errors.push("canvas.layout must be a plain object");
388
+ } else if (canvas.layout.columns !== undefined && canvas.layout.columns !== GRID_COLUMNS) {
389
+ errors.push(`canvas.layout.columns must be ${GRID_COLUMNS}`);
390
+ }
391
+ }
392
+ const seenWidgetIds = new Set();
393
+ if (canvas.widgets !== undefined) {
394
+ validateWidgetArray(canvas.widgets, "canvas.widgets", errors, seenWidgetIds);
395
+ }
396
+ if (canvas.tabs !== undefined) {
397
+ if (!Array.isArray(canvas.tabs)) {
398
+ errors.push("canvas.tabs must be an array");
399
+ } else {
400
+ const seenTabIds = new Set();
401
+ canvas.tabs.forEach((tab, index) => {
402
+ const tabPrefix = `canvas.tabs[${index}]`;
403
+ if (!isPlainObject(tab)) {
404
+ errors.push(`${tabPrefix} must be an object`);
405
+ return;
406
+ }
407
+ if (typeof tab.id !== "string" || !tab.id) {
408
+ errors.push(`${tabPrefix}.id must be a non-empty string`);
409
+ } else if (seenTabIds.has(tab.id)) {
410
+ errors.push(`${tabPrefix}.id duplicates an earlier tab id`);
411
+ } else {
412
+ seenTabIds.add(tab.id);
413
+ }
414
+ if (typeof tab.name !== "string" || !tab.name) errors.push(`${tabPrefix}.name must be a non-empty string`);
415
+ if (tab.widgets !== undefined) validateWidgetArray(tab.widgets, `${tabPrefix}.widgets`, errors, seenWidgetIds);
416
+ });
417
+ if (canvas.activeTabId !== undefined && !seenTabIds.has(canvas.activeTabId)) {
418
+ errors.push("canvas.activeTabId must match an existing tab id");
419
+ }
420
+ }
421
+ }
422
+ if (canvas.activeTabId !== undefined && typeof canvas.activeTabId !== "string") {
423
+ errors.push("canvas.activeTabId must be a string");
424
+ }
425
+ }
426
+
427
+ function validateWorkspaceConfig(nextConfig) {
428
+ if (!isPlainObject(nextConfig)) {
429
+ const error = new Error("workspace config must be a plain object");
430
+ error.code = "INVALID_WORKSPACE_CONFIG";
431
+ error.details = ["root must be a plain object"];
432
+ throw error;
433
+ }
434
+ const errors = [];
435
+ for (const key of Object.keys(nextConfig)) {
436
+ if (!KNOWN_FIELDS.includes(key)) errors.push(`unknown top-level field: ${key}`);
437
+ }
438
+ if (nextConfig.dashboards !== undefined) validateDashboardArray(nextConfig.dashboards, errors);
439
+ if (nextConfig.widgetTypes !== undefined && !Array.isArray(nextConfig.widgetTypes)) errors.push("widgetTypes must be an array");
440
+ if (nextConfig.canvas !== undefined) validateCanvasConfig(nextConfig.canvas, errors);
441
+ if (errors.length) {
442
+ const error = new Error(`invalid workspace config: ${errors.join("; ")}`);
443
+ error.code = "INVALID_WORKSPACE_CONFIG";
444
+ error.details = errors;
445
+ throw error;
446
+ }
447
+ }
448
+
449
+ export {
450
+ DASHBOARD_TEMPLATES,
451
+ GRID_COLUMNS,
452
+ GRID_ROWS,
453
+ KNOWN_DATA_BINDING_MODES,
454
+ KNOWN_FIELDS,
455
+ KNOWN_WIDGET_KINDS,
456
+ SAMPLE_DATA_BINDINGS,
457
+ SAMPLE_VIEW_ROWS,
458
+ WIDGET_SCHEMA_CONTRACTS,
459
+ defaultConfigFor,
460
+ validateWorkspaceConfig
461
+ };