@growthub/cli 0.9.6 → 0.9.8

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.
@@ -3,6 +3,9 @@ const GRID_ROWS = 16;
3
3
  const KNOWN_WIDGET_KINDS = ["chart", "view", "iframe", "rich-text"];
4
4
  const KNOWN_FIELDS = ["dashboards", "widgetTypes", "canvas"];
5
5
  const KNOWN_DATA_BINDING_MODES = ["manual", "json", "csv"];
6
+ const WORKSPACE_TEMPLATE_KIND = "growthub-workspace-template";
7
+ const WORKSPACE_TEMPLATE_VERSION = 1;
8
+ const WORKSPACE_TEMPLATE_SOURCE = "growthub-custom-workspace-starter-v1";
6
9
 
7
10
  const WIDGET_SCHEMA_CONTRACTS = {
8
11
  WidgetPosition: {
@@ -121,12 +124,25 @@ const DASHBOARD_TEMPLATES = [
121
124
  id: "blank",
122
125
  name: "Blank",
123
126
  description: "Empty governed canvas",
127
+ category: "blank",
128
+ bestFor: ["Custom layouts", "Fresh starts"],
129
+ tags: ["blank", "starter"],
130
+ preview: { layout: "empty", summary: "Start from an empty fixed-grid canvas" },
131
+ dashboard: { name: "Blank", status: "draft" },
124
132
  widgets: []
125
133
  },
126
134
  {
127
135
  id: "client-portal",
128
136
  name: "Client Portal",
129
137
  description: "Client status, documents, and embedded portal area",
138
+ category: "agency",
139
+ bestFor: ["Agencies", "Consultants", "Client delivery"],
140
+ tags: ["client", "portal", "delivery"],
141
+ preview: {
142
+ layout: "multi-panel",
143
+ summary: "Client summary, companies table, portal embed, and delivery health"
144
+ },
145
+ dashboard: { name: "Client Portal", status: "draft" },
130
146
  widgets: [
131
147
  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
148
  createWidget("view", "Companies", { x: 4, y: 0, w: 5, h: 5 }),
@@ -138,6 +154,14 @@ const DASHBOARD_TEMPLATES = [
138
154
  id: "content-ops",
139
155
  name: "Content Ops",
140
156
  description: "Editorial pipeline and review snapshot",
157
+ category: "content",
158
+ bestFor: ["Editorial teams", "Content marketers", "Content reviewers"],
159
+ tags: ["content", "editorial", "review"],
160
+ preview: {
161
+ layout: "queue-and-mix",
162
+ summary: "Content queue, publishing mix chart, and review notes"
163
+ },
164
+ dashboard: { name: "Content Ops", status: "draft" },
141
165
  widgets: [
142
166
  createWidget("view", "Content Queue", { x: 0, y: 0, w: 5, h: 5 }, {
143
167
  source: "Content",
@@ -158,6 +182,14 @@ const DASHBOARD_TEMPLATES = [
158
182
  id: "reporting-dashboard",
159
183
  name: "Reporting Dashboard",
160
184
  description: "KPIs, table, and executive readout",
185
+ category: "reporting",
186
+ bestFor: ["Executives", "Analytics teams", "Operations"],
187
+ tags: ["kpi", "reporting", "analytics"],
188
+ preview: {
189
+ layout: "kpi-grid",
190
+ summary: "Pipeline trend, conversion chart, performance table, executive summary"
191
+ },
192
+ dashboard: { name: "Reporting Dashboard", status: "draft" },
161
193
  widgets: [
162
194
  createWidget("chart", "Pipeline Trend", { x: 0, y: 0, w: 4, h: 5 }, { values: [42, 58, 63, 71, 86], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
163
195
  createWidget("chart", "Conversion", { x: 4, y: 0, w: 4, h: 5 }, { values: [28, 36, 44, 39, 52], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
@@ -169,6 +201,14 @@ const DASHBOARD_TEMPLATES = [
169
201
  id: "creative-review",
170
202
  name: "Creative Review",
171
203
  description: "Creative artifact embed and approval notes",
204
+ category: "creative",
205
+ bestFor: ["Creative leads", "Designers", "Account managers"],
206
+ tags: ["creative", "review", "approvals"],
207
+ preview: {
208
+ layout: "embed-and-queue",
209
+ summary: "Creative preview embed, approval notes, and review queue"
210
+ },
211
+ dashboard: { name: "Creative Review", status: "draft" },
172
212
  widgets: [
173
213
  createWidget("iframe", "Creative Preview", { x: 0, y: 0, w: 7, h: 6 }, { url: "" }),
174
214
  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: [] } }),
@@ -189,6 +229,14 @@ const DASHBOARD_TEMPLATES = [
189
229
  id: "agency-delivery",
190
230
  name: "Agency Delivery",
191
231
  description: "Agency workstream, KPI, and delivery notes",
232
+ category: "agency",
233
+ bestFor: ["Agencies", "Delivery leads", "Producers"],
234
+ tags: ["agency", "delivery", "ops"],
235
+ preview: {
236
+ layout: "delivery-grid",
237
+ summary: "Delivery board, utilization chart, client commitments, delivery portal"
238
+ },
239
+ dashboard: { name: "Agency Delivery", status: "draft" },
192
240
  widgets: [
193
241
  createWidget("view", "Delivery Board", { x: 0, y: 0, w: 5, h: 5 }, {
194
242
  source: "Tasks",
@@ -306,9 +354,46 @@ function validateDashboardArray(dashboards, errors) {
306
354
  if (dashboard.status !== undefined && !["draft", "active", "archived"].includes(dashboard.status)) {
307
355
  errors.push(`${prefix}.status must be draft, active, or archived`);
308
356
  }
357
+ if (dashboard.tabs !== undefined) {
358
+ validateDashboardTabs(dashboard.tabs, dashboard.activeTabId, `${prefix}.tabs`, errors, new Set());
359
+ }
360
+ if (dashboard.activeTabId !== undefined && typeof dashboard.activeTabId !== "string") {
361
+ errors.push(`${prefix}.activeTabId must be a string`);
362
+ }
309
363
  });
310
364
  }
311
365
 
366
+ function validateDashboardTabs(tabs, activeTabId, contextPath, errors, seenWidgetIds) {
367
+ if (!Array.isArray(tabs)) {
368
+ errors.push(`${contextPath} must be an array`);
369
+ return;
370
+ }
371
+ if (!tabs.length) {
372
+ errors.push(`${contextPath} must include at least one tab`);
373
+ return;
374
+ }
375
+ const seenTabIds = new Set();
376
+ tabs.forEach((tab, index) => {
377
+ const tabPrefix = `${contextPath}[${index}]`;
378
+ if (!isPlainObject(tab)) {
379
+ errors.push(`${tabPrefix} must be an object`);
380
+ return;
381
+ }
382
+ if (typeof tab.id !== "string" || !tab.id) {
383
+ errors.push(`${tabPrefix}.id must be a non-empty string`);
384
+ } else if (seenTabIds.has(tab.id)) {
385
+ errors.push(`${tabPrefix}.id duplicates an earlier tab id`);
386
+ } else {
387
+ seenTabIds.add(tab.id);
388
+ }
389
+ if (typeof tab.name !== "string" || !tab.name) errors.push(`${tabPrefix}.name must be a non-empty string`);
390
+ validateWidgetArray(tab.widgets || [], `${tabPrefix}.widgets`, errors, seenWidgetIds);
391
+ });
392
+ if (activeTabId !== undefined && !seenTabIds.has(activeTabId)) {
393
+ errors.push(`${contextPath.replace(/\.tabs$/, "")}.activeTabId must match an existing tab id`);
394
+ }
395
+ }
396
+
312
397
  function validateWidgetArray(widgets, contextPath, errors, seenIds) {
313
398
  if (!Array.isArray(widgets)) {
314
399
  errors.push(`${contextPath} must be an array`);
@@ -424,6 +509,220 @@ function validateCanvasConfig(canvas, errors) {
424
509
  }
425
510
  }
426
511
 
512
+ function validateTemplateWidgetArray(widgets, contextPath, errors) {
513
+ if (!Array.isArray(widgets)) {
514
+ errors.push(`${contextPath} must be an array`);
515
+ return;
516
+ }
517
+ const occupied = new Map();
518
+ widgets.forEach((widget, index) => {
519
+ const prefix = `${contextPath}[${index}]`;
520
+ if (!isPlainObject(widget)) {
521
+ errors.push(`${prefix} must be an object`);
522
+ return;
523
+ }
524
+ if (!KNOWN_WIDGET_KINDS.includes(widget.kind)) {
525
+ errors.push(`${prefix}.kind must be one of ${KNOWN_WIDGET_KINDS.join(", ")}`);
526
+ }
527
+ if (typeof widget.title !== "string" || !widget.title) {
528
+ errors.push(`${prefix}.title must be a non-empty string`);
529
+ }
530
+ if (!isPlainObject(widget.position)) {
531
+ errors.push(`${prefix}.position must be an object`);
532
+ return;
533
+ }
534
+ for (const k of ["x", "y", "w", "h"]) {
535
+ if (!isFiniteInt(widget.position[k])) errors.push(`${prefix}.position.${k} must be a finite integer`);
536
+ }
537
+ if (
538
+ isFiniteInt(widget.position.x) &&
539
+ isFiniteInt(widget.position.w) &&
540
+ (widget.position.x < 0 || widget.position.w < 1 || widget.position.x + widget.position.w > GRID_COLUMNS)
541
+ ) {
542
+ errors.push(`${prefix} x/w out of [0..${GRID_COLUMNS}] grid`);
543
+ }
544
+ if (
545
+ isFiniteInt(widget.position.y) &&
546
+ isFiniteInt(widget.position.h) &&
547
+ (widget.position.y < 0 || widget.position.h < 1 || widget.position.y + widget.position.h > GRID_ROWS)
548
+ ) {
549
+ errors.push(`${prefix} y/h out of [0..${GRID_ROWS}] grid`);
550
+ }
551
+ if (
552
+ isFiniteInt(widget.position.x) &&
553
+ isFiniteInt(widget.position.y) &&
554
+ isFiniteInt(widget.position.w) &&
555
+ isFiniteInt(widget.position.h)
556
+ ) {
557
+ for (let dx = 0; dx < widget.position.w; dx += 1) {
558
+ for (let dy = 0; dy < widget.position.h; dy += 1) {
559
+ const cell = `${widget.position.x + dx}:${widget.position.y + dy}`;
560
+ const previous = occupied.get(cell);
561
+ if (previous) {
562
+ errors.push(`${prefix} overlaps ${previous} at grid cell ${cell}`);
563
+ } else {
564
+ occupied.set(cell, `${prefix}.position`);
565
+ }
566
+ }
567
+ }
568
+ }
569
+ validateWidgetConfig(widget.kind, widget.config, `${prefix}.config`, errors);
570
+ });
571
+ }
572
+
573
+ function normalizeWorkspaceTemplate(template) {
574
+ if (!isPlainObject(template)) return template;
575
+ const widgets = Array.isArray(template.widgets) ? template.widgets : [];
576
+ const tags = Array.isArray(template.tags) ? template.tags.filter((tag) => typeof tag === "string") : [];
577
+ const bestFor = Array.isArray(template.bestFor) ? template.bestFor.filter((item) => typeof item === "string") : [];
578
+ const preview = isPlainObject(template.preview)
579
+ ? { layout: template.preview.layout || "custom", summary: template.preview.summary || "" }
580
+ : { layout: "custom", summary: "" };
581
+ const dashboard = isPlainObject(template.dashboard)
582
+ ? {
583
+ name: typeof template.dashboard.name === "string" && template.dashboard.name ? template.dashboard.name : template.name || "Untitled",
584
+ status: ["draft", "active", "archived"].includes(template.dashboard.status) ? template.dashboard.status : "draft"
585
+ }
586
+ : { name: template.name || "Untitled", status: "draft" };
587
+ return {
588
+ id: template.id,
589
+ name: template.name,
590
+ description: typeof template.description === "string" ? template.description : "",
591
+ category: typeof template.category === "string" && template.category ? template.category : "custom",
592
+ bestFor,
593
+ tags,
594
+ widgetCount: widgets.length,
595
+ preview,
596
+ dashboard,
597
+ widgets
598
+ };
599
+ }
600
+
601
+ function validateWorkspaceTemplate(template) {
602
+ if (!isPlainObject(template)) {
603
+ const error = new Error("workspace template must be a plain object");
604
+ error.code = "INVALID_WORKSPACE_TEMPLATE";
605
+ error.details = ["root must be a plain object"];
606
+ throw error;
607
+ }
608
+ const errors = [];
609
+ if (typeof template.id !== "string" || !template.id) errors.push("template.id must be a non-empty string");
610
+ if (typeof template.name !== "string" || !template.name) errors.push("template.name must be a non-empty string");
611
+ if (template.description !== undefined && typeof template.description !== "string") {
612
+ errors.push("template.description must be a string");
613
+ }
614
+ if (template.category !== undefined && typeof template.category !== "string") {
615
+ errors.push("template.category must be a string");
616
+ }
617
+ if (template.bestFor !== undefined) validateStringArray(template.bestFor, "template.bestFor", errors);
618
+ if (template.tags !== undefined) validateStringArray(template.tags, "template.tags", errors);
619
+ if (template.preview !== undefined) {
620
+ if (!isPlainObject(template.preview)) errors.push("template.preview must be a plain object");
621
+ }
622
+ if (template.dashboard !== undefined) {
623
+ if (!isPlainObject(template.dashboard)) {
624
+ errors.push("template.dashboard must be a plain object");
625
+ } else {
626
+ if (typeof template.dashboard.name !== "string" || !template.dashboard.name) {
627
+ errors.push("template.dashboard.name must be a non-empty string");
628
+ }
629
+ if (template.dashboard.status !== undefined && !["draft", "active", "archived"].includes(template.dashboard.status)) {
630
+ errors.push("template.dashboard.status must be draft, active, or archived");
631
+ }
632
+ }
633
+ }
634
+ if (template.widgets !== undefined) {
635
+ validateTemplateWidgetArray(template.widgets, "template.widgets", errors);
636
+ }
637
+ if (errors.length) {
638
+ const error = new Error(`invalid workspace template: ${errors.join("; ")}`);
639
+ error.code = "INVALID_WORKSPACE_TEMPLATE";
640
+ error.details = errors;
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ function requireIdFactory(idFactory) {
646
+ if (typeof idFactory !== "function") {
647
+ const error = new Error("idFactory function is required to clone a template");
648
+ error.code = "MISSING_TEMPLATE_ID_FACTORY";
649
+ throw error;
650
+ }
651
+ }
652
+
653
+ function cloneTemplateWidgets(template, idFactory) {
654
+ const widgets = Array.isArray(template.widgets) ? template.widgets : [];
655
+ return widgets.map((widget) => ({
656
+ id: idFactory("widget"),
657
+ kind: widget.kind,
658
+ title: widget.title,
659
+ position: { ...widget.position },
660
+ config: widget.config !== undefined ? JSON.parse(JSON.stringify(widget.config)) : defaultConfigFor(widget.kind)
661
+ }));
662
+ }
663
+
664
+ function cloneTemplateToTab(template, options = {}) {
665
+ validateWorkspaceTemplate(template);
666
+ requireIdFactory(options.idFactory);
667
+ const widgets = cloneTemplateWidgets(template, options.idFactory);
668
+ return {
669
+ id: options.idFactory("tab"),
670
+ name: typeof options.tabName === "string" && options.tabName ? options.tabName : template.name,
671
+ widgets
672
+ };
673
+ }
674
+
675
+ function cloneTemplateToDashboard(template, options = {}) {
676
+ validateWorkspaceTemplate(template);
677
+ requireIdFactory(options.idFactory);
678
+ const tab = cloneTemplateToTab(template, { tabName: template.name, idFactory: options.idFactory });
679
+ const baseDashboard = isPlainObject(template.dashboard) ? template.dashboard : {};
680
+ return {
681
+ dashboard: {
682
+ id: options.idFactory("dashboard"),
683
+ name: typeof options.dashboardName === "string" && options.dashboardName ? options.dashboardName : baseDashboard.name || template.name,
684
+ createdBy: "Workspace owner",
685
+ updatedAt: "new",
686
+ status: ["draft", "active", "archived"].includes(baseDashboard.status) ? baseDashboard.status : "draft"
687
+ },
688
+ tab
689
+ };
690
+ }
691
+
692
+ function unwrapWorkspaceTemplateImport(parsed) {
693
+ if (!isPlainObject(parsed)) {
694
+ const error = new Error("template import must be a plain object");
695
+ error.code = "INVALID_WORKSPACE_TEMPLATE_IMPORT";
696
+ throw error;
697
+ }
698
+ if (parsed.kind !== undefined && parsed.kind !== WORKSPACE_TEMPLATE_KIND) {
699
+ const error = new Error(`unrecognized template kind: ${parsed.kind}`);
700
+ error.code = "INVALID_WORKSPACE_TEMPLATE_IMPORT";
701
+ throw error;
702
+ }
703
+ if (parsed.kind === WORKSPACE_TEMPLATE_KIND) {
704
+ if (!isPlainObject(parsed.payload)) {
705
+ const error = new Error("template import payload must be a plain object");
706
+ error.code = "INVALID_WORKSPACE_TEMPLATE_IMPORT";
707
+ throw error;
708
+ }
709
+ return parsed.payload;
710
+ }
711
+ return parsed;
712
+ }
713
+
714
+ function wrapWorkspaceTemplateExport(payload, metadata = {}) {
715
+ return {
716
+ version: WORKSPACE_TEMPLATE_VERSION,
717
+ kind: WORKSPACE_TEMPLATE_KIND,
718
+ exportedAt: new Date().toISOString(),
719
+ source: WORKSPACE_TEMPLATE_SOURCE,
720
+ name: typeof metadata.name === "string" && metadata.name ? metadata.name : "Workspace template",
721
+ description: typeof metadata.description === "string" ? metadata.description : "",
722
+ payload
723
+ };
724
+ }
725
+
427
726
  function validateWorkspaceConfig(nextConfig) {
428
727
  if (!isPlainObject(nextConfig)) {
429
728
  const error = new Error("workspace config must be a plain object");
@@ -456,6 +755,16 @@ export {
456
755
  SAMPLE_DATA_BINDINGS,
457
756
  SAMPLE_VIEW_ROWS,
458
757
  WIDGET_SCHEMA_CONTRACTS,
758
+ WORKSPACE_TEMPLATE_KIND,
759
+ WORKSPACE_TEMPLATE_SOURCE,
760
+ WORKSPACE_TEMPLATE_VERSION,
761
+ cloneTemplateToDashboard,
762
+ cloneTemplateToTab,
459
763
  defaultConfigFor,
460
- validateWorkspaceConfig
764
+ normalizeWorkspaceTemplate,
765
+ unwrapWorkspaceTemplateImport,
766
+ validateTemplateWidgetArray,
767
+ validateWorkspaceConfig,
768
+ validateWorkspaceTemplate,
769
+ wrapWorkspaceTemplateExport
461
770
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growthub/cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
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": {