@growthub/cli 0.13.8 → 0.13.9

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,13 @@
1
+ import { NextResponse } from "next/server";
2
+ import { listLocalCodexSites } from "@/lib/codex-sites-local-state";
3
+
4
+ async function GET() {
5
+ const sites = await listLocalCodexSites();
6
+ return NextResponse.json({
7
+ ok: true,
8
+ source: "codex-local-session-state",
9
+ sites
10
+ });
11
+ }
12
+
13
+ export { GET };
@@ -95,6 +95,12 @@ import {
95
95
  pluralize,
96
96
  textColorForAccent,
97
97
  } from "./dm-shared.jsx";
98
+ import {
99
+ CODEX_SITES_OBJECT_ID,
100
+ codexSiteRecordToRow,
101
+ isCodexSiteUrl,
102
+ normalizeCodexSiteRecord,
103
+ } from "@/lib/codex-sites-workspace-adapter";
98
104
 
99
105
  // ─── Object type definitions for the type-picker step ────────────────────────
100
106
 
@@ -1015,6 +1021,95 @@ function SandboxRecordFields({
1015
1021
  );
1016
1022
  }
1017
1023
 
1024
+ function CodexSitesRecordFields({ draft, setDraft, table, saving, onSave, rowIndex }) {
1025
+ const [sites, setSites] = useState([]);
1026
+ const [loadingSites, setLoadingSites] = useState(false);
1027
+ const [sitesMessage, setSitesMessage] = useState("");
1028
+ const selectedUrl = String(draft?.url || "").trim();
1029
+
1030
+ useEffect(() => {
1031
+ let cancelled = false;
1032
+ setLoadingSites(true);
1033
+ setSitesMessage("");
1034
+ fetch("/api/workspace/codex-sites", { cache: "no-store" })
1035
+ .then((res) => res.json())
1036
+ .then((payload) => {
1037
+ if (cancelled) return;
1038
+ const nextSites = Array.isArray(payload.sites)
1039
+ ? payload.sites.map((site) => normalizeCodexSiteRecord(site)).filter((site) => isCodexSiteUrl(site.url))
1040
+ : [];
1041
+ setSites(nextSites);
1042
+ setSitesMessage(nextSites.length ? "" : "No Codex Sites are available from the workspace adapter.");
1043
+ })
1044
+ .catch((error) => {
1045
+ if (cancelled) return;
1046
+ setSites([]);
1047
+ setSitesMessage(error?.message || "Codex Sites adapter unavailable.");
1048
+ })
1049
+ .finally(() => {
1050
+ if (!cancelled) setLoadingSites(false);
1051
+ });
1052
+ return () => { cancelled = true; };
1053
+ }, []);
1054
+
1055
+ function patchFields(fields) {
1056
+ setDraft((current) => ({ ...current, ...fields }));
1057
+ onSave((config) => Object.entries(fields).reduce(
1058
+ (nextConfig, [column, value]) => updateTableCell(nextConfig, table, rowIndex, column, value),
1059
+ config
1060
+ ));
1061
+ }
1062
+
1063
+ function selectSite(url) {
1064
+ const site = sites.find((item) => item.url === url);
1065
+ if (!site) return;
1066
+ patchFields(codexSiteRecordToRow(site));
1067
+ }
1068
+
1069
+ return (
1070
+ <div className="dm-codex-sites-config">
1071
+ <DrawerSection title="Codex Site Binding" defaultOpen>
1072
+ <label className="dm-record-field">
1073
+ <span>Available site</span>
1074
+ <StaticSelect
1075
+ value={selectedUrl}
1076
+ disabled={!table.mutable || saving || loadingSites || sites.length === 0}
1077
+ placeholder={loadingSites ? "Loading Codex Sites..." : "Select Codex Site..."}
1078
+ options={sites.map((site) => ({
1079
+ value: site.url,
1080
+ label: site.Name,
1081
+ source: site.url,
1082
+ }))}
1083
+ onChange={selectSite}
1084
+ />
1085
+ {sitesMessage && <span className="dm-cell-empty">{sitesMessage}</span>}
1086
+ </label>
1087
+ {selectedUrl && (
1088
+ <a className="dm-btn-outline dm-codex-sites-open-link" href={selectedUrl} target="_blank" rel="noreferrer">
1089
+ <Link2 size={13} />Open selected site
1090
+ </a>
1091
+ )}
1092
+ </DrawerSection>
1093
+ <DrawerSection title="Bound Row" defaultOpen>
1094
+ {["Name", "app", "client", "url", "status", "accessMode", "dashboardId", "lastRecordedAt", "notes"].map((column) => (
1095
+ <RecordFieldEditor
1096
+ key={column}
1097
+ table={table}
1098
+ tables={[]}
1099
+ column={column}
1100
+ value={String(draft?.[column] ?? "")}
1101
+ saving={saving}
1102
+ editable={false}
1103
+ onDraft={() => {}}
1104
+ onCommit={() => {}}
1105
+ onExpandJson={() => {}}
1106
+ />
1107
+ ))}
1108
+ </DrawerSection>
1109
+ </div>
1110
+ );
1111
+ }
1112
+
1018
1113
  function DataModelRecordDrawer({
1019
1114
  table,
1020
1115
  tables,
@@ -1086,6 +1181,7 @@ function DataModelRecordDrawer({
1086
1181
 
1087
1182
  const isApiRegistry = table.objectType === "api-registry";
1088
1183
  const isSandbox = table.objectType === "sandbox-environment";
1184
+ const isCodexSitesObject = table.objectId === CODEX_SITES_OBJECT_ID;
1089
1185
  const isDirty = JSON.stringify(draft || {}) !== JSON.stringify(row || {}) || JSON.stringify(pendingColumns) !== JSON.stringify(table.columns || []) || JSON.stringify(pendingHidden) !== JSON.stringify(table.fieldSettings?.hidden || []);
1090
1186
 
1091
1187
  function updateField(column, value) {
@@ -1561,6 +1657,15 @@ function DataModelRecordDrawer({
1561
1657
  onOpenGraphSidecar={openWorkflowView}
1562
1658
  onOpenTraceSidecar={openTraceSidecar}
1563
1659
  />
1660
+ ) : isCodexSitesObject ? (
1661
+ <CodexSitesRecordFields
1662
+ draft={draft}
1663
+ setDraft={setDraft}
1664
+ table={table}
1665
+ saving={saving}
1666
+ onSave={onSave}
1667
+ rowIndex={rowIndex}
1668
+ />
1564
1669
  ) : groupRecordColumns(table.columns || []).map((section) => (
1565
1670
  <DrawerSection key={section.title} title={section.title}>
1566
1671
  {section.columns.map((column) => (
@@ -857,7 +857,8 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
857
857
  .workspace-dashboard-title {
858
858
  min-width: 0;
859
859
  }
860
- .workspace-dashboard-title button {
860
+ .workspace-dashboard-title button,
861
+ .workspace-dashboard-title a {
861
862
  min-width: 0;
862
863
  display: inline-flex;
863
864
  align-items: center;
@@ -870,6 +871,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
870
871
  overflow: hidden;
871
872
  padding: 0;
872
873
  text-align: left;
874
+ text-decoration: none;
873
875
  text-overflow: ellipsis;
874
876
  white-space: nowrap;
875
877
  }
@@ -953,17 +955,24 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
953
955
  background: #ffffff;
954
956
  box-shadow: 0 12px 30px rgba(15, 23, 42, .16);
955
957
  }
956
- .workspace-row-action-menu button {
958
+ .workspace-row-action-menu button,
959
+ .workspace-row-action-menu a {
957
960
  width: 100%;
958
961
  min-height: 30px;
962
+ display: inline-flex;
963
+ align-items: center;
959
964
  justify-content: flex-start;
960
965
  border: 0;
961
966
  border-radius: 6px;
962
967
  background: transparent;
963
968
  color: #344054;
969
+ font: inherit;
970
+ font-size: 12px;
964
971
  padding: 0 9px;
972
+ text-decoration: none;
965
973
  }
966
- .workspace-row-action-menu button:hover {
974
+ .workspace-row-action-menu button:hover,
975
+ .workspace-row-action-menu a:hover {
967
976
  background: #f5f5f5;
968
977
  border-color: transparent;
969
978
  }
@@ -3079,6 +3088,55 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
3079
3088
  border-top: 1px solid #eeeeec;
3080
3089
  padding: 20px 0;
3081
3090
  }
3091
+ .workspace-settings-accordion {
3092
+ gap: 12px;
3093
+ }
3094
+ .workspace-settings-accordion-trigger {
3095
+ width: 100%;
3096
+ min-height: 34px;
3097
+ display: flex;
3098
+ align-items: center;
3099
+ justify-content: space-between;
3100
+ gap: 14px;
3101
+ border: 0;
3102
+ background: transparent;
3103
+ color: inherit;
3104
+ cursor: pointer;
3105
+ font: inherit;
3106
+ padding: 0;
3107
+ text-align: left;
3108
+ }
3109
+ .workspace-settings-accordion-trigger > span {
3110
+ min-width: 0;
3111
+ display: grid;
3112
+ gap: 3px;
3113
+ }
3114
+ .workspace-settings-accordion-trigger h3 {
3115
+ margin: 0;
3116
+ }
3117
+ .workspace-settings-accordion-trigger em {
3118
+ min-width: 0;
3119
+ display: block;
3120
+ color: #888;
3121
+ font-size: 12px;
3122
+ font-style: normal;
3123
+ overflow: hidden;
3124
+ text-overflow: ellipsis;
3125
+ white-space: nowrap;
3126
+ }
3127
+ .workspace-settings-accordion-trigger svg {
3128
+ flex: 0 0 auto;
3129
+ color: #777;
3130
+ transition: transform .16s ease;
3131
+ }
3132
+ .workspace-settings-accordion.is-open .workspace-settings-accordion-trigger svg {
3133
+ transform: rotate(180deg);
3134
+ }
3135
+ .workspace-settings-accordion-body {
3136
+ min-height: 0;
3137
+ display: grid;
3138
+ gap: 12px;
3139
+ }
3082
3140
  .workspace-apps-linkage-section {
3083
3141
  flex: 0 0 auto;
3084
3142
  padding-bottom: 16px;
@@ -3089,6 +3147,9 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
3089
3147
  grid-template-rows: auto minmax(0, 1fr);
3090
3148
  padding-bottom: 0;
3091
3149
  }
3150
+ .workspace-apps-list-section .workspace-settings-accordion-body {
3151
+ min-height: 0;
3152
+ }
3092
3153
  .workspace-settings-section:first-of-type {
3093
3154
  border-top: 0;
3094
3155
  padding-top: 0;
@@ -3577,6 +3638,52 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
3577
3638
  font-size: 12px;
3578
3639
  line-height: 1.4;
3579
3640
  }
3641
+ .workspace-settings-action {
3642
+ min-height: 32px;
3643
+ display: inline-flex;
3644
+ align-items: center;
3645
+ gap: 6px;
3646
+ border: 1px solid #dedede;
3647
+ border-radius: 6px;
3648
+ background: #fff;
3649
+ color: #333;
3650
+ font: inherit;
3651
+ font-size: 12px;
3652
+ padding: 0 10px;
3653
+ }
3654
+ .workspace-settings-codex-sites-list {
3655
+ display: grid;
3656
+ gap: 6px;
3657
+ padding: 0 14px 0;
3658
+ }
3659
+ .workspace-settings-codex-sites-list a,
3660
+ .workspace-settings-codex-sites-list button {
3661
+ min-height: 34px;
3662
+ display: grid;
3663
+ grid-template-columns: minmax(0, 1fr) minmax(0, .8fr) auto;
3664
+ gap: 10px;
3665
+ align-items: center;
3666
+ border: 1px solid #ececea;
3667
+ border-radius: 6px;
3668
+ background: #fff;
3669
+ color: #333;
3670
+ font: inherit;
3671
+ font-size: 12px;
3672
+ text-align: left;
3673
+ text-decoration: none;
3674
+ padding: 0 10px;
3675
+ }
3676
+ .workspace-settings-codex-sites-list span,
3677
+ .workspace-settings-codex-sites-list em {
3678
+ min-width: 0;
3679
+ overflow: hidden;
3680
+ text-overflow: ellipsis;
3681
+ white-space: nowrap;
3682
+ }
3683
+ .workspace-settings-codex-sites-list em {
3684
+ color: #777;
3685
+ font-style: normal;
3686
+ }
3580
3687
 
3581
3688
  /* Source picker */
3582
3689
  .workspace-source-list {
@@ -4639,7 +4746,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
4639
4746
  .dm-detail-v2-title h2 { margin: 0; font-size: 15px; font-weight: 600; color: #111827; flex: 1; }
4640
4747
  .dm-detail-v2-meta { display: flex; align-items: center; gap: 10px; padding-left: 21px; font-size: 12px; color: #9ca3af; }
4641
4748
  .dm-detail-v2-meta code { font-size: 11px; color: #6b7280; background: #f3f4f6; border-radius: 4px; padding: 2px 6px; }
4642
- .dm-detail-v3 { border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; overflow: hidden; }
4749
+ .dm-detail-v3 { border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; overflow: visible; }
4643
4750
  .dm-picker { position: relative; min-width: 280px; }
4644
4751
  .dm-picker-trigger { width: min(420px, 100%); display: inline-flex; align-items: center; gap: 10px; min-height: 40px; border: 1px solid #dbe2ea; border-radius: 10px; background: #fff; color: #0f172a; box-shadow: 0 1px 2px rgba(15,23,42,.05); font: inherit; padding: 0 12px; cursor: pointer; text-align: left; }
4645
4752
  .dm-picker.open .dm-picker-trigger { border-color: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.14); }
@@ -4680,6 +4787,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
4680
4787
  .workspace-toolbar-actions .dm-picker-popover {
4681
4788
  left: auto;
4682
4789
  right: 0;
4790
+ z-index: 220;
4683
4791
  }
4684
4792
 
4685
4793
  /* Active-object title block in the workspace toolbar — replaces the
@@ -4729,13 +4837,13 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
4729
4837
  font-weight: 400;
4730
4838
  color: #6b7280;
4731
4839
  }
4732
- .dm-picker-popover { position: absolute; top: calc(100% + 8px); left: 0; z-index: 50; width: 360px; display: grid; gap: 10px; padding: 12px; border: 1px solid #dbe2ea; border-radius: 14px; background: #fff; box-shadow: 0 28px 80px rgba(15,23,42,.24); overflow: visible; }
4840
+ .dm-picker-popover { position: absolute; top: calc(100% + 8px); left: 0; z-index: 220; width: min(360px, calc(100vw - 32px)); max-height: min(520px, calc(100vh - 120px)); display: grid; gap: 10px; padding: 12px; border: 1px solid #dbe2ea; border-radius: 14px; background: #fff; box-shadow: 0 28px 80px rgba(15,23,42,.24); overflow-y: auto; overflow-x: visible; }
4733
4841
  .dm-picker-tabs { display: inline-flex; gap: 6px; padding: 4px; border-radius: 999px; background: #f8fafc; }
4734
4842
  .dm-picker-tabs button { height: 28px; border: 0; border-radius: 999px; background: transparent; color: #64748b; font: inherit; font-size: 12px; padding: 0 10px; cursor: pointer; }
4735
4843
  .dm-picker-tabs button.active { background: #fff; color: #111827; box-shadow: 0 1px 2px rgba(15,23,42,.08); }
4736
4844
  .dm-picker-section { display: grid; gap: 8px; }
4737
4845
  .dm-picker-section > p { margin: 0; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: .06em; }
4738
- .dm-picker-scroll { display: grid; gap: 4px; max-height: 220px; overflow-y: auto; padding-right: 10px; scrollbar-gutter: stable; }
4846
+ .dm-picker-scroll { display: grid; gap: 4px; max-height: none; overflow: visible; padding-right: 10px; scrollbar-gutter: stable; }
4739
4847
  .dm-picker-item { position: relative; display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; }
4740
4848
  .dm-picker-row { width: 100%; display: inline-flex; align-items: center; gap: 8px; min-width: 0; min-height: 34px; border: 0; border-radius: 9px; background: transparent; color: #334155; font: inherit; font-size: 12px; font-weight: 500; padding: 0 10px; cursor: pointer; text-align: left; }
4741
4849
  .dm-picker-row:hover, .dm-picker-item.active .dm-picker-row { background: #f1f5f9; color: #111827; }
@@ -4746,7 +4854,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
4746
4854
  .dm-picker-icon-btn:hover { color: #111827; border-color: #cbd5e1; }
4747
4855
  .dm-picker-icon-btn.danger:hover { color: #b91c1c; border-color: #fecaca; background: #fef2f2; }
4748
4856
  .dm-picker-lock { margin-left: auto; color: #94a3b8; }
4749
- .dm-picker-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 55; display: grid; gap: 4px; min-width: 156px; padding: 8px; border: 1px solid #dbe2ea; border-radius: 10px; background: #fff; box-shadow: 0 20px 44px rgba(15,23,42,.2), 0 4px 12px rgba(15,23,42,.08); }
4857
+ .dm-picker-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 240; display: grid; gap: 4px; min-width: 156px; padding: 8px; border: 1px solid #dbe2ea; border-radius: 10px; background: #fff; box-shadow: 0 20px 44px rgba(15,23,42,.2), 0 4px 12px rgba(15,23,42,.08); }
4750
4858
  .dm-picker-menu button { display: inline-flex; align-items: center; gap: 8px; height: 30px; border: 0; border-radius: 7px; background: transparent; color: #334155; font: inherit; font-size: 12px; padding: 0 10px; cursor: pointer; text-align: left; }
4751
4859
  .dm-picker-menu button:hover { background: #f1f5f9; color: #111827; }
4752
4860
  .dm-picker-menu button.danger:hover { background: #fef2f2; color: #b91c1c; }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { ExternalLink, Rocket } from "lucide-react";
4
+ import {
5
+ CODEX_SITES_OBJECT_ID,
6
+ ensureCodexSitesDataModel,
7
+ isCodexSiteUrl
8
+ } from "@/lib/codex-sites-workspace-adapter";
9
+ import { SettingsAccordionSection } from "./settings-accordion-section.jsx";
10
+
11
+ function CodexSitesDataModelCard({ apps, dataModel }) {
12
+ const objects = Array.isArray(dataModel?.objects) ? dataModel.objects : [];
13
+ const object = objects.find((item) => item?.id === CODEX_SITES_OBJECT_ID) || null;
14
+ const rows = Array.isArray(object?.rows) ? object.rows : [];
15
+ const liveRows = rows.filter((row) => {
16
+ const status = String(row?.status || "").toLowerCase();
17
+ return isCodexSiteUrl(row?.url) && (status === "live" || status === "active");
18
+ });
19
+ const liveCount = liveRows.length;
20
+ const appCount = new Set(rows.map((row) => row?.app).filter(Boolean)).size;
21
+
22
+ async function openDataModel() {
23
+ if (!object) {
24
+ const nextDataModel = ensureCodexSitesDataModel(dataModel, apps);
25
+ const response = await fetch("/api/workspace", {
26
+ method: "PATCH",
27
+ headers: { "content-type": "application/json" },
28
+ body: JSON.stringify({ dataModel: nextDataModel })
29
+ });
30
+ if (!response.ok) {
31
+ const payload = await response.json().catch(() => ({}));
32
+ window.alert(payload.error || "Failed to create Codex Sites object.");
33
+ return;
34
+ }
35
+ }
36
+ window.location.href = `/data-model?object=${encodeURIComponent(CODEX_SITES_OBJECT_ID)}`;
37
+ }
38
+
39
+ return <SettingsAccordionSection
40
+ id="codex-sites"
41
+ title="Codex Sites"
42
+ summary={`${rows.length} site${rows.length === 1 ? "" : "s"} · ${liveCount} live · ${appCount || apps?.length || 0} app${(appCount || apps?.length || 0) === 1 ? "" : "s"}`}
43
+ className="workspace-apps-linkage-section workspace-codex-sites-section"
44
+ >
45
+ <div className="workspace-app-row">
46
+ <span className="workspace-provider-mark"><Rocket size={15} /></span>
47
+ <div>
48
+ <strong>Codex Sites</strong>
49
+ <p>Manage Codex-hosted site URLs as a governed custom Data Model object attached to workspace apps and clients.</p>
50
+ <div className="workspace-integration-meta">
51
+ <span>{object ? "configured" : "not configured"}</span>
52
+ <span>{rows.length} site{rows.length === 1 ? "" : "s"}</span>
53
+ <span>{liveCount} live</span>
54
+ <span>{appCount || apps?.length || 0} app{(appCount || apps?.length || 0) === 1 ? "" : "s"}</span>
55
+ </div>
56
+ </div>
57
+ <button type="button" className="workspace-settings-action" onClick={openDataModel}>
58
+ <ExternalLink size={14} />{object ? "Manage" : "Set up"}
59
+ </button>
60
+ </div>
61
+ {rows.length ? <div className="workspace-settings-codex-sites-list">
62
+ {rows.slice(0, 4).map((row, index) => isCodexSiteUrl(row?.url) ? (
63
+ <a key={row.id || row.Name || index} href={row.url} target="_blank" rel="noreferrer">
64
+ <span>{row.Name || `Site ${index + 1}`}</span>
65
+ <em>{row.client || "Workspace"} · {row.app || "apps/workspace"}</em>
66
+ <ExternalLink size={13} />
67
+ </a>
68
+ ) : (
69
+ <button key={row.id || row.Name || index} type="button" onClick={openDataModel}>
70
+ <span>{row.Name || `Site ${index + 1}`}</span>
71
+ <em>{row.client || "Workspace"} · {row.status || "draft"}</em>
72
+ </button>
73
+ ))}
74
+ </div> : null}
75
+ </SettingsAccordionSection>;
76
+ }
77
+
78
+ export {
79
+ CODEX_SITES_OBJECT_ID,
80
+ CodexSitesDataModelCard
81
+ };
@@ -3,6 +3,8 @@ import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readWorkspaceConfig } from "@/lib/workspace-config";
5
5
  import { AppsList } from "./apps-list.jsx";
6
+ import { CodexSitesDataModelCard } from "./codex-sites-data-model-card.jsx";
7
+ import { SettingsAccordionGroup, SettingsAccordionSection } from "./settings-accordion-section.jsx";
6
8
 
7
9
  async function readForkMetadata() {
8
10
  try {
@@ -82,24 +84,39 @@ async function AppsSettingsPage() {
82
84
  <div className="workspace-settings-card-heading">
83
85
  <div>
84
86
  <h2>Apps</h2>
85
- <p>Read-only workspace app, bridge, and fork metadata already available to this workspace.</p>
87
+ <p>Workspace apps discovered from the local apps directory and governed Data Model configuration.</p>
86
88
  </div>
87
89
  </div>
88
90
 
89
- <section className="workspace-settings-section workspace-apps-linkage-section">
90
- <h3>Workspace Linkage</h3>
91
- <div className="workspace-settings-kv">
92
- <span>Workspace</span><code>{workspaceConfig.id || "workspace-builder-default"}</code>
93
- <span>Fork</span><code>{fork?.forkId || "local fork metadata unavailable"}</code>
94
- <span>Kit</span><code>{fork?.kitId || workspaceConfig.provenance?.mirrors || "growthub-custom-workspace-starter-v1"}</code>
95
- <span>Bridge</span><code>{bridge?.status || bridge?.id || "not connected"}</code>
96
- </div>
97
- </section>
91
+ <SettingsAccordionGroup defaultOpenId="workspace-apps">
92
+ <SettingsAccordionSection
93
+ id="workspace-apps"
94
+ title="Workspace Apps"
95
+ summary={`${apps.length} app${apps.length === 1 ? "" : "s"} discovered from directory and config.`}
96
+ className="workspace-apps-list-section"
97
+ >
98
+ <AppsList apps={apps} />
99
+ </SettingsAccordionSection>
100
+
101
+ <SettingsAccordionSection
102
+ id="workspace-linkage"
103
+ title="Workspace Linkage"
104
+ summary="Fork, kit, and bridge identity for this workspace."
105
+ className="workspace-apps-linkage-section"
106
+ >
107
+ <div className="workspace-settings-kv">
108
+ <span>Workspace</span><code>{workspaceConfig.id || "workspace-builder-default"}</code>
109
+ <span>Fork</span><code>{fork?.forkId || "local fork metadata unavailable"}</code>
110
+ <span>Kit</span><code>{fork?.kitId || workspaceConfig.provenance?.mirrors || "growthub-custom-workspace-starter-v1"}</code>
111
+ <span>Bridge</span><code>{bridge?.status || bridge?.id || "not connected"}</code>
112
+ </div>
113
+ </SettingsAccordionSection>
98
114
 
99
- <section className="workspace-settings-section workspace-apps-list-section">
100
- <h3>Workspace Apps</h3>
101
- <AppsList apps={apps} />
102
- </section>
115
+ <CodexSitesDataModelCard
116
+ apps={apps}
117
+ dataModel={workspaceConfig.dataModel || {}}
118
+ />
119
+ </SettingsAccordionGroup>
103
120
  </section>
104
121
  </SettingsShell>;
105
122
  }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useState } from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+
6
+ const SettingsAccordionContext = createContext(null);
7
+
8
+ function SettingsAccordionGroup({ defaultOpenId, children }) {
9
+ const [openId, setOpenId] = useState(defaultOpenId || null);
10
+ return <SettingsAccordionContext.Provider value={{ openId, setOpenId }}>
11
+ {children}
12
+ </SettingsAccordionContext.Provider>;
13
+ }
14
+
15
+ function SettingsAccordionSection({ id, title, summary, className = "", defaultOpen = false, children }) {
16
+ const group = useContext(SettingsAccordionContext);
17
+ const [localOpen, setLocalOpen] = useState(defaultOpen);
18
+ const open = group ? group.openId === id : localOpen;
19
+ const sectionClass = [
20
+ "workspace-settings-section",
21
+ "workspace-settings-accordion",
22
+ open ? "is-open" : "is-collapsed",
23
+ className
24
+ ].filter(Boolean).join(" ");
25
+ const toggle = () => {
26
+ if (group) {
27
+ group.setOpenId(open ? null : id);
28
+ return;
29
+ }
30
+ setLocalOpen((value) => !value);
31
+ };
32
+
33
+ return <section className={sectionClass}>
34
+ <button
35
+ type="button"
36
+ className="workspace-settings-accordion-trigger"
37
+ aria-expanded={open}
38
+ onClick={toggle}
39
+ >
40
+ <span>
41
+ <h3>{title}</h3>
42
+ {summary ? <em>{summary}</em> : null}
43
+ </span>
44
+ <ChevronDown size={16} aria-hidden="true" />
45
+ </button>
46
+ {open ? <div className="workspace-settings-accordion-body">{children}</div> : null}
47
+ </section>;
48
+ }
49
+
50
+ export { SettingsAccordionGroup, SettingsAccordionSection };
@@ -45,6 +45,7 @@ import {
45
45
  Plus,
46
46
  Quote,
47
47
  RefreshCw,
48
+ Rocket,
48
49
  Rows3,
49
50
  Save,
50
51
  Search,
@@ -89,6 +90,11 @@ import {
89
90
  } from "@/lib/workspace-chart-values";
90
91
  import { selectObjectFilterableFields, selectObjectSortableFields } from "@/lib/workspace-metadata-selectors";
91
92
  import { deriveWorkspaceActivationState } from "@/lib/workspace-activation";
93
+ import {
94
+ CODEX_SITES_OBJECT_ID,
95
+ ensureCodexSitesDataModel,
96
+ isCodexSiteUrl
97
+ } from "@/lib/codex-sites-workspace-adapter";
92
98
  import { HelperSidecar } from "./data-model/components/HelperSidecar.jsx";
93
99
  import { WorkspaceRail } from "./workspace-rail.jsx";
94
100
  import { WorkspaceActivationPanel } from "./components/WorkspaceActivationPanel.jsx";
@@ -503,6 +509,34 @@ function listBuilderWorkflowItems(config) {
503
509
  });
504
510
  }
505
511
 
512
+ function listBuilderSiteItems(config) {
513
+ const object = getDataModelObject(config, CODEX_SITES_OBJECT_ID);
514
+ const rows = Array.isArray(object?.rows) ? object.rows : [];
515
+ return rows.flatMap((row, index) => {
516
+ const url = String(row?.url || "").trim();
517
+ if (!isCodexSiteUrl(url)) return [];
518
+ const title = String(row?.Name || row?.name || `Codex Site ${index + 1}`).trim();
519
+ const rawStatus = String(row?.status || "").trim().toLowerCase();
520
+ const status = rawStatus === "active" ? "live" : rawStatus || "draft";
521
+ return [{
522
+ type: "site",
523
+ id: String(row?.id || row?.Name || `codex-site-${index + 1}`),
524
+ title,
525
+ itemKind: "Site",
526
+ updatedAt: formatBuilderTimestamp(row?.lastRecordedAt || ""),
527
+ status,
528
+ site: {
529
+ row,
530
+ rowIndex: index,
531
+ title,
532
+ url,
533
+ app: String(row?.app || "apps/workspace").trim(),
534
+ client: String(row?.client || "Workspace").trim()
535
+ }
536
+ }];
537
+ });
538
+ }
539
+
506
540
  function formatBuilderTimestamp(value) {
507
541
  const raw = String(value || "").trim();
508
542
  if (!raw || raw === "new") return raw;
@@ -4246,6 +4280,7 @@ function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig,
4246
4280
  const canvas = config.canvas;
4247
4281
  const dashboards = config.dashboards || [];
4248
4282
  const workflows = useMemo(() => listBuilderWorkflowItems(config), [config]);
4283
+ const sites = useMemo(() => listBuilderSiteItems(config), [config]);
4249
4284
  const builderItems = useMemo(() => {
4250
4285
  const dashboardItems = dashboards.map((dashboard, index) => ({
4251
4286
  type: "dashboard",
@@ -4266,13 +4301,17 @@ function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig,
4266
4301
  status: workflow.lifecycleStatus || "draft",
4267
4302
  workflow
4268
4303
  }));
4304
+ const siteItems = sites.map((site) => ({
4305
+ ...site,
4306
+ updatedAt: site.updatedAt || "new"
4307
+ }));
4269
4308
  const q = builderListFilter.query.trim().toLowerCase();
4270
- return [...dashboardItems, ...workflowItems].filter((item) => {
4309
+ return [...dashboardItems, ...siteItems, ...workflowItems].filter((item) => {
4271
4310
  if (builderListFilter.type !== "all" && item.type !== builderListFilter.type) return false;
4272
4311
  if (!q) return true;
4273
- return [item.title, item.itemKind, item.status, item.type].some((part) => String(part || "").toLowerCase().includes(q));
4312
+ return [item.title, item.itemKind, item.status, item.type, item.site?.client, item.site?.app, item.site?.url].some((part) => String(part || "").toLowerCase().includes(q));
4274
4313
  });
4275
- }, [builderListFilter, dashboards, workflows]);
4314
+ }, [builderListFilter, dashboards, sites, workflows]);
4276
4315
  const resolvedActiveDashboardId = getActiveDashboardId(dashboards, activeDashboardId);
4277
4316
  const resolvedActiveDashboardIndex = activeDashboardIndex(dashboards, resolvedActiveDashboardId);
4278
4317
  const widgetTypes = config.widgetTypes;
@@ -4709,6 +4748,50 @@ function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig,
4709
4748
  }
4710
4749
  }, [config, saving]);
4711
4750
 
4751
+ const createCodexSite = useCallback(async () => {
4752
+ if (saving) return;
4753
+ const objects = Array.isArray(config.dataModel?.objects) ? config.dataModel.objects : [];
4754
+ const hasCodexSitesObject = objects.some((object) => object?.id === CODEX_SITES_OBJECT_ID);
4755
+ if (hasCodexSitesObject) {
4756
+ window.open(`/data-model?object=${encodeURIComponent(CODEX_SITES_OBJECT_ID)}`, "_self");
4757
+ return;
4758
+ }
4759
+ const nextDataModel = ensureCodexSitesDataModel(config.dataModel, []);
4760
+ setSaving(true);
4761
+ try {
4762
+ const response = await fetch("/api/workspace", {
4763
+ method: "PATCH",
4764
+ headers: { "content-type": "application/json" },
4765
+ body: JSON.stringify({ dataModel: nextDataModel })
4766
+ });
4767
+ const payload = await response.json();
4768
+ if (!response.ok || !payload.workspaceConfig) {
4769
+ throw new Error(payload.error || "Failed to create Codex Sites object");
4770
+ }
4771
+ setConfig((prev) => ({ ...prev, dataModel: payload.workspaceConfig.dataModel }));
4772
+ setBuilderListFilter({ type: "site", query: "" });
4773
+ setConfigMessage("Created Codex Sites object");
4774
+ window.open(`/data-model?object=${encodeURIComponent(CODEX_SITES_OBJECT_ID)}`, "_self");
4775
+ } catch (error) {
4776
+ setConfigMessage(error.message || "Failed to create Codex Sites object");
4777
+ } finally {
4778
+ setSaving(false);
4779
+ }
4780
+ }, [config, saving]);
4781
+
4782
+ const manageSite = useCallback((site) => {
4783
+ const rowParam = site?.rowIndex !== undefined ? `&row=${encodeURIComponent(String(site.rowIndex))}` : "";
4784
+ window.open(`/data-model?object=${encodeURIComponent(CODEX_SITES_OBJECT_ID)}${rowParam}`, "_self");
4785
+ }, []);
4786
+
4787
+ const openSite = useCallback((site) => {
4788
+ if (site?.url) {
4789
+ window.open(site.url, "_blank", "noopener,noreferrer");
4790
+ return;
4791
+ }
4792
+ manageSite(site);
4793
+ }, [manageSite]);
4794
+
4712
4795
  const selectDashboard = useCallback((index) => {
4713
4796
  setConfig((prev) => {
4714
4797
  const synced = syncActiveDashboard(prev, activeDashboardId);
@@ -5234,7 +5317,7 @@ function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig,
5234
5317
  }
5235
5318
  const rect = event.currentTarget.getBoundingClientRect();
5236
5319
  const menuWidth = 148;
5237
- const menuHeight = item?.type === "dashboard" ? 136 : 76;
5320
+ const menuHeight = item?.type === "dashboard" ? 136 : item?.type === "site" ? 108 : 76;
5238
5321
  const margin = 8;
5239
5322
  const left = Math.min(
5240
5323
  Math.max(margin, rect.right - menuWidth),
@@ -5916,6 +5999,7 @@ function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig,
5916
5999
  </span>
5917
6000
  ) : null}
5918
6001
  <button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
6002
+ <button type="button" onClick={createCodexSite} disabled={saving}><Rocket size={15} />New Codex Site</button>
5919
6003
  <button type="button" onClick={createWorkflow} disabled={saving}><GitBranch size={15} />New Workflow</button>
5920
6004
  <button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
5921
6005
  </div>}
@@ -5952,13 +6036,14 @@ function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig,
5952
6036
  <section className="workspace-table" id="dashboards" aria-label="Builder">
5953
6037
  <div className="workspace-table-heading">
5954
6038
  <strong>Builder</strong>
5955
- <span>{dashboards.length} dashboard{dashboards.length === 1 ? "" : "s"} · {workflows.length} workflow{workflows.length === 1 ? "" : "s"}</span>
6039
+ <span>{dashboards.length} dashboard{dashboards.length === 1 ? "" : "s"} · {sites.length} site{sites.length === 1 ? "" : "s"} · {workflows.length} workflow{workflows.length === 1 ? "" : "s"}</span>
5956
6040
  </div>
5957
6041
  <div className="workspace-builder-filterbar">
5958
6042
  <div className="workspace-builder-filterbar__segments" role="group" aria-label="Builder item type">
5959
6043
  {[
5960
6044
  ["all", "All"],
5961
6045
  ["dashboard", "Dashboards"],
6046
+ ["site", "Sites"],
5962
6047
  ["workflow", "Workflows"]
5963
6048
  ].map(([type, label]) => (
5964
6049
  <button
@@ -6052,6 +6137,53 @@ function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig,
6052
6137
  </span>
6053
6138
  )}
6054
6139
  </span>
6140
+ </div> : item.type === "site" ? <div className="workspace-table-row" key={item.id}>
6141
+ <span className="workspace-dashboard-title">
6142
+ {item.site.url ? (
6143
+ <a href={item.site.url} target="_blank" rel="noreferrer">{item.title}</a>
6144
+ ) : (
6145
+ <button
6146
+ className="active"
6147
+ onClick={() => manageSite(item.site)}
6148
+ type="button"
6149
+ >{item.title}</button>
6150
+ )}
6151
+ </span>
6152
+ <span>{item.itemKind}</span>
6153
+ <span>{item.updatedAt}</span>
6154
+ <span>
6155
+ <select
6156
+ aria-label={`Status for ${item.title}`}
6157
+ value={item.status || "draft"}
6158
+ disabled
6159
+ >
6160
+ <option value="draft">draft</option>
6161
+ <option value="review">review</option>
6162
+ <option value="live">live</option>
6163
+ <option value="paused">paused</option>
6164
+ </select>
6165
+ </span>
6166
+ <span className="workspace-dashboard-actions">
6167
+ <button
6168
+ type="button"
6169
+ className="workspace-row-action-trigger"
6170
+ aria-label={`Actions for ${item.title}`}
6171
+ onClick={(event) => openBuilderActionMenu(item, event)}
6172
+ >
6173
+ <MoreVertical size={16} aria-hidden="true" />
6174
+ </button>
6175
+ {builderActionMenuId === item.id && (
6176
+ <span className="workspace-row-action-menu" style={builderActionMenuPlacement || undefined}>
6177
+ {item.site.url ? (
6178
+ <a href={item.site.url} target="_blank" rel="noreferrer" onClick={closeBuilderActionMenu}>Open URL</a>
6179
+ ) : (
6180
+ <button type="button" disabled>Open URL</button>
6181
+ )}
6182
+ <button type="button" onClick={() => { closeBuilderActionMenu(); manageSite(item.site); }}>Manage</button>
6183
+ <button type="button" onClick={() => { closeBuilderActionMenu(); window.open("/settings/apps", "_self"); }}>Apps</button>
6184
+ </span>
6185
+ )}
6186
+ </span>
6055
6187
  </div> : <div className="workspace-table-row" key={item.id}>
6056
6188
  <span className="workspace-dashboard-title">
6057
6189
  {editingWorkflowId === item.workflow.id ? <span className="workspace-dashboard-title-editor">
@@ -0,0 +1,139 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { normalizeCodexSiteRecord } from "./codex-sites-workspace-adapter.js";
5
+
6
+ const MAX_SESSION_FILES = 16;
7
+
8
+ function resolveCodexHome() {
9
+ return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
10
+ }
11
+
12
+ function isPlainObject(value) {
13
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+
16
+ function stripSensitiveProjectFields(project) {
17
+ if (!isPlainObject(project)) return null;
18
+ const {
19
+ source_repository_credential: _sourceRepositoryCredential,
20
+ access_policy: _accessPolicy,
21
+ auth_client_id: _authClientId,
22
+ ...safeProject
23
+ } = project;
24
+ return safeProject;
25
+ }
26
+
27
+ function parseJsonMaybe(value) {
28
+ if (isPlainObject(value)) return value;
29
+ if (typeof value !== "string") return null;
30
+ const trimmed = value.trim();
31
+ if (!trimmed) return null;
32
+ try {
33
+ return JSON.parse(trimmed);
34
+ } catch {
35
+ const marker = "Output:";
36
+ const index = trimmed.lastIndexOf(marker);
37
+ if (index === -1) return null;
38
+ const candidate = trimmed.slice(index + marker.length).trim();
39
+ try {
40
+ return JSON.parse(candidate);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ }
46
+
47
+ function collectStructuredPayloads(line) {
48
+ const parsed = parseJsonMaybe(line);
49
+ if (!isPlainObject(parsed)) return [];
50
+ const payload = parsed.payload;
51
+ const values = [];
52
+ const structured = payload?.result?.Ok?.structuredContent;
53
+ if (isPlainObject(structured)) values.push(structured);
54
+ const outputPayload = parseJsonMaybe(payload?.output);
55
+ if (isPlainObject(outputPayload)) values.push(outputPayload);
56
+ return values;
57
+ }
58
+
59
+ function mergeCodexSitePayload(byProjectId, payload) {
60
+ if (!isPlainObject(payload)) return;
61
+ const isProject =
62
+ typeof payload.id === "string" &&
63
+ payload.id.startsWith("appgprj_") &&
64
+ (payload.current_live_url !== undefined || payload.slug || payload.title);
65
+ const isDeployment =
66
+ typeof payload.project_id === "string" &&
67
+ typeof payload.url === "string" &&
68
+ payload.url.startsWith("http");
69
+ if (!isProject && !isDeployment) return;
70
+
71
+ const projectId = isProject ? payload.id : payload.project_id;
72
+ const current = byProjectId.get(projectId) || { id: projectId };
73
+ const next = isProject
74
+ ? { ...current, ...stripSensitiveProjectFields(payload) }
75
+ : {
76
+ ...current,
77
+ id: projectId,
78
+ title: payload.title || current.title,
79
+ current_live_url: payload.url,
80
+ status: payload.status === "succeeded" ? "live" : payload.status || current.status,
81
+ updated_at: payload.updated_at || current.updated_at,
82
+ dashboardId: current.dashboardId || projectId,
83
+ deployment_id: payload.id,
84
+ version_id: payload.version_id
85
+ };
86
+ byProjectId.set(projectId, next);
87
+ }
88
+
89
+ async function listSessionFiles(root) {
90
+ const entries = [];
91
+ async function visit(dir) {
92
+ let children;
93
+ try {
94
+ children = await fs.readdir(dir, { withFileTypes: true });
95
+ } catch {
96
+ return;
97
+ }
98
+ await Promise.all(children.map(async (entry) => {
99
+ const fullPath = path.join(dir, entry.name);
100
+ if (entry.isDirectory()) {
101
+ await visit(fullPath);
102
+ return;
103
+ }
104
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
105
+ const stat = await fs.stat(fullPath).catch(() => null);
106
+ if (stat) entries.push({ path: fullPath, mtimeMs: stat.mtimeMs });
107
+ }
108
+ }));
109
+ }
110
+ await visit(root);
111
+ return entries
112
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
113
+ .slice(0, MAX_SESSION_FILES)
114
+ .map((entry) => entry.path);
115
+ }
116
+
117
+ async function listLocalCodexSites() {
118
+ const codexHome = resolveCodexHome();
119
+ const sessionRoot = path.join(codexHome, "sessions");
120
+ const files = await listSessionFiles(sessionRoot);
121
+ const byProjectId = new Map();
122
+ for (const file of files) {
123
+ const raw = await fs.readFile(file, "utf8").catch(() => "");
124
+ if (!raw.includes("mcp__codex_apps__sites") && !raw.includes("sites_") && !raw.includes("appgprj_")) continue;
125
+ raw.split(/\r?\n/).forEach((line) => {
126
+ collectStructuredPayloads(line).forEach((payload) => mergeCodexSitePayload(byProjectId, payload));
127
+ });
128
+ }
129
+ return Array.from(byProjectId.values())
130
+ .map((site) => normalizeCodexSiteRecord({
131
+ ...site,
132
+ url: site.current_live_url || site.url,
133
+ accessMode: site.access_mode,
134
+ dashboardId: site.dashboardId || site.slug || site.id
135
+ }))
136
+ .filter((site) => site.url);
137
+ }
138
+
139
+ export { listLocalCodexSites };
@@ -0,0 +1,156 @@
1
+ const CODEX_SITES_OBJECT_ID = "workspace-codex-sites";
2
+ const CODEX_SITES_COLUMNS = [
3
+ "Name",
4
+ "app",
5
+ "client",
6
+ "url",
7
+ "status",
8
+ "accessMode",
9
+ "dashboardId",
10
+ "lastRecordedAt",
11
+ "notes"
12
+ ];
13
+ const CODEX_SITES_SOURCE_ID = "codex-sites";
14
+
15
+ function isCodexSiteUrl(value) {
16
+ return /^https?:\/\//i.test(String(value || "").trim());
17
+ }
18
+
19
+ function defaultAppSource(apps) {
20
+ const first = Array.isArray(apps) ? apps.find((app) => app?.source) : null;
21
+ return first?.source || "apps/workspace";
22
+ }
23
+
24
+ function createCodexSitesObject(apps = []) {
25
+ return {
26
+ id: CODEX_SITES_OBJECT_ID,
27
+ label: "Codex Sites",
28
+ source: "Workspace Apps",
29
+ sourceId: CODEX_SITES_OBJECT_ID,
30
+ objectType: "custom",
31
+ icon: "Rocket",
32
+ columns: CODEX_SITES_COLUMNS,
33
+ rows: [],
34
+ binding: {
35
+ mode: "manual",
36
+ source: "Settings / Apps",
37
+ sourceType: "workspace-data-model",
38
+ sourceAuthority: "workspace-config",
39
+ objectId: CODEX_SITES_OBJECT_ID,
40
+ sourceId: CODEX_SITES_SOURCE_ID,
41
+ entityType: "codex-site",
42
+ app: defaultAppSource(apps)
43
+ },
44
+ fieldSettings: {
45
+ hidden: [],
46
+ order: CODEX_SITES_COLUMNS,
47
+ views: [
48
+ {
49
+ id: "codex-sites-live",
50
+ name: "Live",
51
+ favorite: true,
52
+ order: CODEX_SITES_COLUMNS,
53
+ filter: { op: "and", clauses: [{ fieldId: "status", operator: "eq", value: "live" }] }
54
+ },
55
+ {
56
+ id: "codex-sites-review",
57
+ name: "Draft & Review",
58
+ order: CODEX_SITES_COLUMNS,
59
+ filter: { op: "or", clauses: [
60
+ { fieldId: "status", operator: "eq", value: "draft" },
61
+ { fieldId: "status", operator: "eq", value: "review" }
62
+ ] }
63
+ }
64
+ ],
65
+ activeViewId: "codex-sites-live",
66
+ types: {
67
+ Name: "text",
68
+ app: "text",
69
+ client: "text",
70
+ url: "url",
71
+ status: "select",
72
+ accessMode: "select",
73
+ dashboardId: "text",
74
+ lastRecordedAt: "date",
75
+ notes: "text"
76
+ }
77
+ }
78
+ };
79
+ }
80
+
81
+ function normalizeCodexSiteRecord(record = {}) {
82
+ const url = String(record.url || record.liveUrl || record.current_live_url || "").trim();
83
+ return {
84
+ id: String(record.id || record.projectId || record.project_id || record.slug || url).trim(),
85
+ Name: String(record.Name || record.name || record.title || record.slug || "Codex Site").trim(),
86
+ app: String(record.app || record.source || "apps/workspace").trim(),
87
+ client: String(record.client || record.workspace || "Workspace").trim(),
88
+ url,
89
+ status: String(record.status || (url ? "live" : "draft")).trim(),
90
+ accessMode: String(record.accessMode || record.access_mode || "workspace").trim(),
91
+ dashboardId: String(record.dashboardId || record.dashboard_id || record.slug || record.id || "").trim(),
92
+ lastRecordedAt: String(record.lastRecordedAt || record.updated_at || record.created_at || "").trim(),
93
+ notes: String(record.notes || record.description || "").trim()
94
+ };
95
+ }
96
+
97
+ function codexSiteRecordToRow(record = {}) {
98
+ const site = normalizeCodexSiteRecord(record);
99
+ return {
100
+ Name: site.Name,
101
+ app: site.app,
102
+ client: site.client,
103
+ url: site.url,
104
+ status: site.status,
105
+ accessMode: site.accessMode,
106
+ dashboardId: site.dashboardId,
107
+ lastRecordedAt: site.lastRecordedAt || new Date().toISOString(),
108
+ notes: site.notes
109
+ };
110
+ }
111
+
112
+ function recordsFromSourceEntry(entry) {
113
+ if (Array.isArray(entry)) return entry;
114
+ if (Array.isArray(entry?.records)) return entry.records;
115
+ if (Array.isArray(entry?.sites)) return entry.sites;
116
+ if (Array.isArray(entry?.items)) return entry.items;
117
+ return [];
118
+ }
119
+
120
+ function listAvailableCodexSites(workspaceConfig = {}, workspaceSourceRecords = {}) {
121
+ const sidecarRecords = [
122
+ ...recordsFromSourceEntry(workspaceSourceRecords?.[CODEX_SITES_SOURCE_ID]),
123
+ ...recordsFromSourceEntry(workspaceSourceRecords?.[CODEX_SITES_OBJECT_ID])
124
+ ];
125
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
126
+ const object = objects.find((item) => item?.id === CODEX_SITES_OBJECT_ID);
127
+ const rowRecords = Array.isArray(object?.rows) ? object.rows : [];
128
+ const byUrl = new Map();
129
+ [...sidecarRecords, ...rowRecords].forEach((record) => {
130
+ const site = normalizeCodexSiteRecord(record);
131
+ if (!isCodexSiteUrl(site.url)) return;
132
+ byUrl.set(site.url, site);
133
+ });
134
+ return Array.from(byUrl.values());
135
+ }
136
+
137
+ function ensureCodexSitesDataModel(dataModel, apps = []) {
138
+ const objects = Array.isArray(dataModel?.objects) ? dataModel.objects : [];
139
+ if (objects.some((object) => object?.id === CODEX_SITES_OBJECT_ID)) return dataModel || {};
140
+ return {
141
+ ...(dataModel || {}),
142
+ objects: [...objects, createCodexSitesObject(apps)]
143
+ };
144
+ }
145
+
146
+ export {
147
+ CODEX_SITES_COLUMNS,
148
+ CODEX_SITES_OBJECT_ID,
149
+ CODEX_SITES_SOURCE_ID,
150
+ codexSiteRecordToRow,
151
+ createCodexSitesObject,
152
+ ensureCodexSitesDataModel,
153
+ isCodexSiteUrl,
154
+ listAvailableCodexSites,
155
+ normalizeCodexSiteRecord
156
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growthub/cli",
3
- "version": "0.13.8",
3
+ "version": "0.13.9",
4
4
  "description": "CLI control plane for Growthub Local and Agent Workspace as Code: export, fork, inspect, operate, sync, and optionally activate governed AI workspaces.",
5
5
  "type": "module",
6
6
  "bin": {