@growthub/cli 0.9.13 → 0.9.16

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.
Files changed (34) hide show
  1. package/README.md +17 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +1349 -222
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1048 -4
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1540 -433
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +141 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +211 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +126 -7
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +16 -0
  33. package/dist/index.js +1764 -40677
  34. package/package.json +2 -2
@@ -0,0 +1,141 @@
1
+ # Data Sources and API Registry
2
+
3
+ This workspace supports provider-agnostic API-backed data sources without storing credentials in Data Model records.
4
+
5
+ Use this pattern for any external API:
6
+
7
+ 1. Store the provider secret in workspace settings or server env.
8
+ 2. Create an API Registry object.
9
+ 3. Create a Data Source object.
10
+ 4. Set the Data Source `registryId` to the API Registry row `integrationId`.
11
+ 5. Test the record from the Data Model drawer.
12
+ 6. Use the tested Data Source as the widget source.
13
+
14
+ ## Object Types
15
+
16
+ ### Data Source
17
+
18
+ Data Source rows represent the data a widget can consume.
19
+
20
+ Required fields:
21
+
22
+ - `Name`: display name.
23
+ - `registryId`: reference to an API Registry row by `integrationId`.
24
+ - `endpoint`: provider endpoint path or full URL.
25
+ - `authRef`: named secret reference, not the secret value.
26
+ - `baseUrl`: provider base URL.
27
+ - `status`: `untested`, `connected`, or `failed`.
28
+ - `lastTested`: timestamp from the latest test.
29
+ - `lastResponse`: saved JSON response shape from a successful test.
30
+
31
+ ### API Registry
32
+
33
+ API Registry rows represent reusable API request configuration.
34
+
35
+ Required fields:
36
+
37
+ - `integrationId`: stable identifier used by Data Source `registryId`.
38
+ - `authRef`: named secret reference, not the secret value.
39
+ - `baseUrl`: provider base URL.
40
+ - `endpoint`: default endpoint path.
41
+ - `method`: HTTP method.
42
+ - `status`: connection status from testing.
43
+ - `lastTested`: timestamp from the latest test.
44
+ - `lastResponse`: saved JSON response shape.
45
+ - `entityTypes`: comma-separated source types this registry can power.
46
+ - `description`: human-readable notes.
47
+
48
+ ## Credential Rules
49
+
50
+ Never add secret fields such as `apiKey`, `authToken`, password, or bearer token to Data Model objects.
51
+
52
+ Records store only `authRef`. The server resolves that reference to env/settings values. For example, an `authRef` of `LEADSHARK` can resolve to `LEADSHARK`, `LEADSHARK_API_KEY`, or `LEADSHARK_TOKEN`.
53
+
54
+ Provider-specific request headers belong in the server-side test route or provider adapter. The Data Model surface stays credential-free.
55
+
56
+ For APIs that do not use `x-api-key`, add non-secret request metadata to the API Registry row:
57
+
58
+ - `authHeaderName`: header name such as `Authorization`, `X-API-Token`, or `x-api-key`.
59
+ - `authPrefix`: optional value prefix such as `Bearer`.
60
+
61
+ Do not store the secret value in either field.
62
+
63
+ ## Test Flow
64
+
65
+ The Data Model record drawer has a `Test connection` action for Data Source and API Registry rows.
66
+
67
+ For Data Source rows:
68
+
69
+ 1. The server loads the referenced API Registry row from `registryId`.
70
+ 2. It merges registry defaults with the Data Source row.
71
+ 3. It resolves `authRef` server-side.
72
+ 4. It sends the request from the API route.
73
+ 5. A successful response saves `status: connected`, `lastTested`, and JSON `lastResponse`.
74
+ 6. A failed response saves `status: failed` and must not mark the source connected.
75
+
76
+ Only rows with a successful test and parseable saved `lastResponse` qualify as configured sources.
77
+
78
+ ## Widget Source Rules
79
+
80
+ Widget source pickers show Data Model objects from workspace config.
81
+
82
+ API Registry objects are not selectable as widget data sources. They are request configuration records.
83
+
84
+ Data Source objects are selectable only when at least one row is:
85
+
86
+ - `status` equal to `connected`, `approved`, `ok`, or `success`.
87
+ - `lastResponse` is valid saved JSON.
88
+
89
+ This ensures widgets bind only to tested, configured sources with a known returned shape.
90
+
91
+ Sandbox Environment rows are execution records, **not** widget sources. Workspace Builder excludes `objectType: "sandbox-environment"` from source pickers. For serverless sandbox runs, reuse an API Registry integration as the **scheduler webhook** by setting `schedulerRegistryId` on the sandbox row to that row’s `integrationId` (`runLocality: serverless`). See `sandbox-environment-primitive.md` in this folder.
92
+
93
+ ## LeadShark Example
94
+
95
+ LeadShark uses:
96
+
97
+ - Base URL: `https://apex.leadshark.io/api`
98
+ - Leads endpoint: `/leads`
99
+ - Header: `x-api-key`
100
+ - Workspace secret reference: `LEADSHARK`
101
+
102
+ Example API Registry row:
103
+
104
+ ```json
105
+ {
106
+ "integrationId": "leadshark",
107
+ "authRef": "LEADSHARK",
108
+ "baseUrl": "https://apex.leadshark.io/api",
109
+ "endpoint": "/leads",
110
+ "method": "GET",
111
+ "status": "untested",
112
+ "entityTypes": "leads",
113
+ "description": "LeadShark leads API"
114
+ }
115
+ ```
116
+
117
+ Example Data Source row:
118
+
119
+ ```json
120
+ {
121
+ "Name": "LeadShark Leads",
122
+ "registryId": "leadshark",
123
+ "endpoint": "/leads?page=1&limit=5",
124
+ "authRef": "LEADSHARK",
125
+ "baseUrl": "https://apex.leadshark.io/api",
126
+ "status": "untested"
127
+ }
128
+ ```
129
+
130
+ After a successful test, `lastResponse` should contain the provider JSON response shape. For LeadShark leads, the expected top-level shape includes `data` and `pagination`.
131
+
132
+ ## Scaling Pattern
133
+
134
+ This is intentionally API-agnostic. The same primitive works for any provider when it follows the same contract:
135
+
136
+ - One API Registry row per reusable provider request definition.
137
+ - One or more Data Source rows that reference registry records.
138
+ - No secrets in Data Model rows.
139
+ - Server-side test execution.
140
+ - Saved response shape after successful validation.
141
+ - Widgets bind only to tested Data Sources.
@@ -0,0 +1,32 @@
1
+ # Sandbox Environment (governed data object)
2
+
3
+ `objectType: "sandbox-environment"` is an execution-plane manual object alongside Data Source, API Registry, People, Tasks, and Custom tables. Rows live in **`growthub.config.json#dataModel.objects[]`** — the same PATCH allowlist (`dataModel`) and validator (`apps/workspace/lib/workspace-schema.js`) as every other governed object.
4
+
5
+ ## Persistence and upgrades
6
+
7
+ Deployed workspaces that adopted an older sandbox preset **without** `runLocality` / `schedulerRegistryId` columns keep working: `POST /api/workspace/sandbox-run` treats blank or unknown `runLocality` as **`local`** (`normalizeRunLocality` + `DEFAULT_SANDBOX_RUN_LOCALITY`). Persisted rows pick up defaults at **read time**, so operators do not have to replay migrations for existing JSON on disk until they decide to expose the new controls in the table.
8
+
9
+ Operators who want the radios and scheduler FK in the Data Model grid should add columns `runLocality` and `schedulerRegistryId` to the object (matching the preset) and save via the normal PATCH path.
10
+
11
+ ## Where it runs (`runLocality`)
12
+
13
+ | Value | Behaviour |
14
+ | --- | --- |
15
+ | **`local`** (default when unset / empty / unknown) | `lib/adapters/sandboxes/` resolves an adapter (`local-process`, `local-agent-host`, drop-zone). Spawn + capture happen on the Next.js host. Secrets come only from server-resolved **`envRefs`** / env. |
16
+ | **`serverless`** | No local agent-host spawn. Outbound **`POST`** to the URL merged from API Registry row identified by **`schedulerRegistryId`** (same pattern as Data Source **`registryId`**: `integrationId`, `authRef`, `baseUrl`, `endpoint`, headers resolved server-side). Body kind **`growthub-sandbox-run-v1`**. **`local-agent-host`** is rejected in this locality. Responses map to **`stdout`** / **`stderr`** / **`exitCode`** so `lastResponse` and **`growthub.source-records.json`** stay uniform. |
17
+
18
+ The scheduler webhook is deliberately thin **any** reachable HTTPS handler (Supabase Edge, Upstash/QStash-queued worker, `vercel.json` cron hitting your URL, DIY). Postgres / KV-backed workflow queues in workspace config describe *where persistence lives*, not the sandbox row itself; the **`schedulerRegistryId`** row is only the outbound HTTP binding.
19
+
20
+ Agents and streamed APIs elsewhere in the sandbox stay orthogonal: serverless swaps **who invokes** the sandbox run boundary, not the rest of workspace networking.
21
+
22
+ ## Credential surface
23
+
24
+ Sandbox rows reference **`authRef` / named env refs** — never literals in browser or config records. Scheduling uses the referenced API Registry row’s **`authRef`** merge rules identical to **`/api/workspace/test-source`**.
25
+
26
+ ## Not a widget source
27
+
28
+ Workspace Builder excludes **`sandbox-environment`** from View widget bindings (execution records, not tabular KPI sources). See **`data-sources-api-registry.md`** in this folder.
29
+
30
+ ## Extension points
31
+
32
+ - Custom adapters: `apps/workspace/lib/adapters/sandboxes/adapters/` (see `README.md` there).
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Resolver dynamic loader — filesystem-safe, ESM-compatible.
3
+ *
4
+ * Reads every `.js` file from `lib/adapters/integrations/resolvers/` and
5
+ * side-effect-imports it so each file can call `registerSourceResolver()`.
6
+ *
7
+ * This runs server-side only. The browser never sees or calls this module.
8
+ * In read-only / production runtimes with no resolver files the registry
9
+ * stays empty — the refresh button and test route gracefully skip unknown
10
+ * integrationIds and surface them in the `skipped` array.
11
+ *
12
+ * Called once per route handler invocation (Next.js module cache means it
13
+ * will only do real I/O on the first call in each worker process).
14
+ */
15
+
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+
20
+ const loaded = new Set();
21
+ let loadAttempted = false;
22
+
23
+ async function loadAllResolvers() {
24
+ if (loadAttempted) return;
25
+ loadAttempted = true;
26
+ const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/integrations/resolvers");
27
+ try {
28
+ const entries = await fs.readdir(resolversDir);
29
+ const jsFiles = entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
30
+ await Promise.all(
31
+ jsFiles.map(async (file) => {
32
+ if (loaded.has(file)) return;
33
+ try {
34
+ const absolutePath = path.join(resolversDir, file);
35
+ await import(/*turbopackIgnore: true*/ pathToFileURL(absolutePath).href);
36
+ loaded.add(file);
37
+ } catch {
38
+ // Malformed resolver — skip silently; operator needs to fix the file
39
+ }
40
+ })
41
+ );
42
+ } catch {
43
+ // resolvers directory missing or empty — normal for fresh upstream kit
44
+ }
45
+ }
46
+
47
+ async function listResolverFiles() {
48
+ const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/integrations/resolvers");
49
+ try {
50
+ const entries = await fs.readdir(resolversDir);
51
+ return entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ export { loadAllResolvers, listResolverFiles };
@@ -0,0 +1,133 @@
1
+ # Integration Resolvers
2
+
3
+ Drop one `.js` file per integration here. Each file calls `registerSourceResolver()` once at module load.
4
+
5
+ **No resolver files ship in the upstream kit.** This directory is the extension point — operators add their own files for whatever integrations they connect (Asana, Linear, HubSpot, a custom API, a BYO token endpoint, a webhook, anything).
6
+
7
+ ## Resolver shape
8
+
9
+ ```js
10
+ import { registerSourceResolver } from "../source-resolver-registry.js";
11
+
12
+ registerSourceResolver({
13
+ integrationId: "your-provider-slug", // must match binding.integrationId in dataModel
14
+ entityTypes: ["object.type"], // list of types this resolver handles
15
+ listEntities: async (config, connection) => NormalizedEntity[],
16
+ fetchRecords: async (config, connection, binding) => Record[]
17
+ });
18
+ ```
19
+
20
+ ## Agent CLI commands
21
+
22
+ All commands output JSON. Pipe through `| jq` for filtering.
23
+
24
+ ```bash
25
+ # List registered resolver IDs and on-disk files
26
+ curl -s http://localhost:3000/api/workspace/resolvers
27
+
28
+ # Test a resolver without saving (returns preview rows or error reason)
29
+ curl -s -X POST http://localhost:3000/api/workspace/test-source \
30
+ -H "Content-Type: application/json" \
31
+ -d '{
32
+ "integrationId": "your-provider-slug",
33
+ "binding": {
34
+ "entityType": "your.entity.type",
35
+ "sourceStorage": "workspace-source-records",
36
+ "sourceId": "your-source-id"
37
+ }
38
+ }'
39
+
40
+ # Trigger a full refresh for one or more source IDs on the active tab
41
+ curl -s -X POST http://localhost:3000/api/workspace/refresh-sources \
42
+ -H "Content-Type: application/json" \
43
+ -d '{"sourceIds": ["your-source-id"]}'
44
+
45
+ # Register a data model object backed by this resolver (persists to growthub.config.json)
46
+ curl -s -X PATCH http://localhost:3000/api/workspace \
47
+ -H "Content-Type: application/json" \
48
+ -d '{
49
+ "dataModel": {
50
+ "objects": [
51
+ {
52
+ "id": "your-source-id",
53
+ "label": "Your Source Label",
54
+ "objectType": "your.object.type",
55
+ "storageType": "manual-object",
56
+ "columns": ["col1", "col2"],
57
+ "rows": [],
58
+ "sourceId": "your-source-id",
59
+ "binding": {
60
+ "mode": "integration",
61
+ "sourceStorage": "workspace-source-records",
62
+ "integrationId": "your-provider-slug",
63
+ "sourceId": "your-source-id",
64
+ "entityType": "your.entity.type"
65
+ }
66
+ }
67
+ ]
68
+ }
69
+ }'
70
+
71
+ # Read back the full workspace config (data model objects, canvas, adapters)
72
+ curl -s http://localhost:3000/api/workspace
73
+ ```
74
+
75
+ ### Response contracts
76
+
77
+ **`GET /api/workspace/resolvers`**
78
+ ```json
79
+ {
80
+ "files": ["google-analytics.js"],
81
+ "registeredIds": ["google-analytics"],
82
+ "canUpload": true
83
+ }
84
+ ```
85
+
86
+ **`POST /api/workspace/test-source` — resolver found, token missing**
87
+ ```json
88
+ {
89
+ "ok": false,
90
+ "reason": "fetch-error",
91
+ "integrationId": "google-analytics",
92
+ "error": "GOOGLE_ANALYTICS_ACCESS_TOKEN is not set. Add it to .env.local to enable GA4 data refresh."
93
+ }
94
+ ```
95
+
96
+ **`POST /api/workspace/test-source` — resolver found, records returned**
97
+ ```json
98
+ {
99
+ "ok": true,
100
+ "integrationId": "google-analytics",
101
+ "recordCount": 120,
102
+ "columns": ["date", "channel", "device", "sessions", "activeUsers", "bounceRate"],
103
+ "preview": [{ "date": "20260501", "channel": "Organic Search", "sessions": 412 }],
104
+ "entityTypes": ["ga4.traffic"]
105
+ }
106
+ ```
107
+
108
+ **`POST /api/workspace/refresh-sources`**
109
+ ```json
110
+ { "refreshed": ["your-source-id"], "skipped": [] }
111
+ ```
112
+
113
+ ## Data model and source dropdown flow
114
+
115
+ 1. Add resolver file here → resolver registers on server start.
116
+ 2. `PATCH /api/workspace` with a `dataModel.objects` entry that sets `binding.sourceStorage: "workspace-source-records"` and `binding.integrationId` matching this resolver.
117
+ 3. Source dropdown in the workspace builder shows the object as a selectable dynamic source (resolver-backed objects appear in the **Dynamic sources** section).
118
+ 4. User clicks Refresh on the tab bar → `POST /api/workspace/refresh-sources` → resolver `fetchRecords` runs → records persisted → widgets re-render.
119
+
120
+ ## How the refresh button connects to this
121
+
122
+ 1. User configures a `dataModel.object` with `binding.sourceStorage: "workspace-source-records"` and `binding.integrationId: "your-provider-slug"`.
123
+ 2. User clicks Refresh on the tab bar.
124
+ 3. The builder collects all `sourceId` values from live-backed widgets on the active tab and POSTs them to `/api/workspace/refresh-sources`.
125
+ 4. The route looks up the resolver by `integrationId`, calls `fetchRecords`, and persists normalized records via `writeWorkspaceSourceRecords`.
126
+ 5. The builder reloads from `/api/workspace` and the widgets render the updated rows.
127
+
128
+ ## Auth contract
129
+
130
+ - **Tokens stay server-side.** Provider tokens live in the Growthub bridge / BYO env var store — never in workspace config or client state.
131
+ - The bridge confirms which integrations are connected. It does not proxy data. The resolver reads `process.env.YOUR_PROVIDER_TOKEN` and calls the provider API directly.
132
+ - `config` passed to your resolver is the server-side `adapterConfig` from `readAdapterConfig()`. Read `process.env.YOUR_TOKEN` server-side inside the resolver. Never forward it to the client.
133
+ - Normalize provider responses to display-safe records before returning — no raw API payloads, no token-adjacent fields.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Google Analytics 4 — Source Resolver
3
+ *
4
+ * Auth path:
5
+ * GROWTHUB_BRIDGE_ACCESS_TOKEN (set in .env.local by `growthub starter init`)
6
+ * → GET /api/cli/profile?view=integration&provider=google-analytics
7
+ * → short-lived Google OAuth accessToken
8
+ * → GA4 Admin API (accountSummaries → property list)
9
+ * → GA4 Data API (runReport)
10
+ *
11
+ * Required env vars (set automatically in .env.local):
12
+ * GROWTHUB_BRIDGE_ACCESS_TOKEN
13
+ * GROWTHUB_BRIDGE_USER_ID (optional — improves routing)
14
+ * GROWTHUB_BRIDGE_BASE_URL (default: https://www.growthub.ai)
15
+ *
16
+ * Optional env vars:
17
+ * GOOGLE_ANALYTICS_PROPERTY_ID override property for fetchRecords
18
+ * GOOGLE_ANALYTICS_DATE_RANGE_DAYS lookback window (default 30)
19
+ */
20
+
21
+ import { registerSourceResolver } from "../source-resolver-registry.js";
22
+
23
+ const GA4_DATA_API = "https://analyticsdata.googleapis.com/v1beta";
24
+ const GA4_ADMIN_API = "https://analyticsadmin.googleapis.com/v1beta";
25
+
26
+ /**
27
+ * Resolve the GA4 access token.
28
+ *
29
+ * GROWTHUB_BRIDGE_ACCESS_TOKEN is the Google OAuth access token vended by the
30
+ * Growthub bridge for the connected google-analytics integration. It is used
31
+ * directly — no secondary bridge API call required.
32
+ */
33
+ function resolveGA4AccessToken() {
34
+ const token = process.env.GROWTHUB_BRIDGE_ACCESS_TOKEN;
35
+ if (!token) {
36
+ throw new Error(
37
+ "GROWTHUB_BRIDGE_ACCESS_TOKEN is not set. Export it from growthub.ai/settings/connections."
38
+ );
39
+ }
40
+ return token;
41
+ }
42
+
43
+ /**
44
+ * List all GA4 properties for the authenticated account.
45
+ */
46
+ async function listGA4Properties(ga4Token) {
47
+ const res = await fetch(`${GA4_ADMIN_API}/accountSummaries`, {
48
+ headers: { Authorization: `Bearer ${ga4Token}`, Accept: "application/json" },
49
+ });
50
+ if (!res.ok) {
51
+ throw new Error(`GA4 Admin API ${res.status}: ${await res.text()}`);
52
+ }
53
+ const data = await res.json();
54
+ return (data.accountSummaries || []).flatMap((account) =>
55
+ (account.propertySummaries || []).map((prop) => ({
56
+ id: prop.property,
57
+ label: `${prop.displayName} — ${account.displayName}`,
58
+ type: "ga4.account",
59
+ meta: {
60
+ propertyId: prop.property,
61
+ accountId: account.account,
62
+ accountName: account.displayName,
63
+ },
64
+ }))
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Run a GA4 traffic report for the given property.
70
+ */
71
+ async function fetchGA4Report(ga4Token, propertyId, days = 30) {
72
+ const body = {
73
+ dateRanges: [{ startDate: `${days}daysAgo`, endDate: "today" }],
74
+ dimensions: [
75
+ { name: "date" },
76
+ { name: "sessionDefaultChannelGroup" },
77
+ { name: "deviceCategory" },
78
+ ],
79
+ metrics: [
80
+ { name: "sessions" },
81
+ { name: "activeUsers" },
82
+ { name: "bounceRate" },
83
+ { name: "averageSessionDuration" },
84
+ { name: "conversions" },
85
+ ],
86
+ orderBys: [{ dimension: { dimensionName: "date" }, desc: true }],
87
+ limit: 500,
88
+ };
89
+
90
+ const res = await fetch(`${GA4_DATA_API}/${propertyId}:runReport`, {
91
+ method: "POST",
92
+ headers: {
93
+ Authorization: `Bearer ${ga4Token}`,
94
+ "Content-Type": "application/json",
95
+ Accept: "application/json",
96
+ },
97
+ body: JSON.stringify(body),
98
+ });
99
+
100
+ if (!res.ok) {
101
+ throw new Error(`GA4 Data API ${res.status}: ${await res.text()}`);
102
+ }
103
+
104
+ const report = await res.json();
105
+ const dimHeaders = (report.dimensionHeaders || []).map((h) => h.name);
106
+ const metHeaders = (report.metricHeaders || []).map((h) => h.name);
107
+
108
+ return (report.rows || []).map((row) => {
109
+ const dims = Object.fromEntries(
110
+ dimHeaders.map((name, i) => [name, row.dimensionValues?.[i]?.value ?? null])
111
+ );
112
+ const mets = Object.fromEntries(
113
+ metHeaders.map((name, i) => {
114
+ const raw = row.metricValues?.[i]?.value ?? null;
115
+ return [name, raw !== null ? Number(raw) : null];
116
+ })
117
+ );
118
+ return {
119
+ date: dims.date ?? null,
120
+ channel: dims.sessionDefaultChannelGroup ?? null,
121
+ device: dims.deviceCategory ?? null,
122
+ sessions: mets.sessions ?? 0,
123
+ activeUsers: mets.activeUsers ?? 0,
124
+ bounceRate: mets.bounceRate != null ? Number((mets.bounceRate * 100).toFixed(2)) : null,
125
+ avgSessionDuration: mets.averageSessionDuration != null
126
+ ? Number(mets.averageSessionDuration.toFixed(1))
127
+ : null,
128
+ conversions: mets.conversions ?? 0,
129
+ };
130
+ });
131
+ }
132
+
133
+ registerSourceResolver({
134
+ integrationId: "google-analytics",
135
+
136
+ entityTypes: ["ga4.traffic", "ga4.account"],
137
+
138
+ listEntities: async (_config, _connection) => {
139
+ const ga4Token = resolveGA4AccessToken();
140
+ return await listGA4Properties(ga4Token);
141
+ },
142
+
143
+ fetchRecords: async (_config, _connection, binding) => {
144
+ const ga4Token = resolveGA4AccessToken();
145
+
146
+ const propertyId =
147
+ binding?.propertyId ||
148
+ process.env.GOOGLE_ANALYTICS_PROPERTY_ID;
149
+
150
+ if (!propertyId) {
151
+ throw new Error(
152
+ "No GA4 property ID configured. Set binding.propertyId in the data model object " +
153
+ "or GOOGLE_ANALYTICS_PROPERTY_ID in .env.local."
154
+ );
155
+ }
156
+
157
+ const days = Number(process.env.GOOGLE_ANALYTICS_DATE_RANGE_DAYS || 30);
158
+ return fetchGA4Report(ga4Token, propertyId, days);
159
+ },
160
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Source Resolver Registry — provider-agnostic dispatch layer.
3
+ *
4
+ * Contract:
5
+ * Each integration ships its own resolver file under
6
+ * `lib/adapters/integrations/resolvers/<id>.js` and calls
7
+ * `registerSourceResolver` once at module load time.
8
+ *
9
+ * Resolver shape:
10
+ * {
11
+ * integrationId: string, // stable provider slug, e.g. "asana"
12
+ * entityTypes: string[], // e.g. ["project.tasks", "workspace.users"]
13
+ * listEntities: async (config, connection) => NormalizedEntity[],
14
+ * fetchRecords: async (config, connection, binding) => Record[]
15
+ * }
16
+ *
17
+ * The route and the refresh button reference this registry only — they have
18
+ * zero knowledge of Asana, Linear, HubSpot, or any other provider. Every
19
+ * provider-specific fetch lives in its own resolver file and is completely
20
+ * isolated from the others.
21
+ */
22
+
23
+ // globalThis singleton so resolver files loaded via dynamic file:// import
24
+ // share the same Map instance as the Next.js-bundled copy of this module.
25
+ if (!globalThis.__growthubSourceResolverRegistry) {
26
+ globalThis.__growthubSourceResolverRegistry = new Map();
27
+ }
28
+ const registry = globalThis.__growthubSourceResolverRegistry;
29
+
30
+ /**
31
+ * Register a source resolver. Called once per provider at module load.
32
+ * Calling again with the same integrationId replaces the existing resolver.
33
+ */
34
+ function registerSourceResolver(resolver) {
35
+ if (!resolver || typeof resolver !== "object") {
36
+ throw new Error("registerSourceResolver: resolver must be a plain object");
37
+ }
38
+ if (typeof resolver.integrationId !== "string" || !resolver.integrationId.trim()) {
39
+ throw new Error("registerSourceResolver: resolver.integrationId must be a non-empty string");
40
+ }
41
+ if (typeof resolver.fetchRecords !== "function") {
42
+ throw new Error(`registerSourceResolver(${resolver.integrationId}): resolver.fetchRecords must be a function`);
43
+ }
44
+ registry.set(resolver.integrationId.trim(), resolver);
45
+ }
46
+
47
+ /**
48
+ * Look up a registered resolver by integration id.
49
+ * Returns null when no resolver has been registered for that id.
50
+ */
51
+ function getSourceResolver(integrationId) {
52
+ if (typeof integrationId !== "string" || !integrationId.trim()) return null;
53
+ return registry.get(integrationId.trim()) || null;
54
+ }
55
+
56
+ /**
57
+ * List all registered integration ids. Useful for diagnostics.
58
+ */
59
+ function listRegisteredResolvers() {
60
+ return Array.from(registry.keys());
61
+ }
62
+
63
+ /**
64
+ * Describe all registered resolvers — returns provider-agnostic metadata declared
65
+ * by each resolver file. The UI uses this to render generic controls without any
66
+ * knowledge of specific providers.
67
+ *
68
+ * Shape per entry:
69
+ * {
70
+ * integrationId: string,
71
+ * entityTypes: string[], // declared by the resolver
72
+ * hasListEntities: boolean, // true if resolver.listEntities is a function
73
+ * configSchema: SchemaField[] | null // optional declarative params schema
74
+ * }
75
+ */
76
+ function describeRegisteredResolvers() {
77
+ return Array.from(registry.entries()).map(([id, resolver]) => ({
78
+ integrationId: id,
79
+ entityTypes: Array.isArray(resolver.entityTypes) ? resolver.entityTypes : [],
80
+ hasListEntities: typeof resolver.listEntities === "function",
81
+ configSchema: Array.isArray(resolver.configSchema) ? resolver.configSchema : null,
82
+ }));
83
+ }
84
+
85
+ export { registerSourceResolver, getSourceResolver, listRegisteredResolvers, describeRegisteredResolvers };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Sandbox adapter dynamic loader — filesystem-safe, ESM-compatible.
3
+ *
4
+ * Reads every `.js` file from `lib/adapters/sandboxes/adapters/` and
5
+ * side-effect-imports it so each file can call `registerSandboxAdapter()`.
6
+ *
7
+ * Mirrors `lib/adapters/integrations/resolver-loader.js` exactly so operators
8
+ * recognize the drop-zone pattern. Server-side only — the browser never sees
9
+ * this module.
10
+ *
11
+ * The default `local-process` adapter ships under
12
+ * `lib/adapters/sandboxes/default-local-process.js` and is loaded eagerly by
13
+ * `index.js`; this loader is for additional drop-zone adapters added by
14
+ * forks (e.g. `fly-machines.js`, `e2b.js`, `modal.js`).
15
+ */
16
+
17
+ import { promises as fs } from "node:fs";
18
+ import path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ const loaded = new Set();
22
+ let loadAttempted = false;
23
+
24
+ async function loadAllSandboxAdapters() {
25
+ if (loadAttempted) return;
26
+ loadAttempted = true;
27
+ const adaptersDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/sandboxes/adapters");
28
+ try {
29
+ const entries = await fs.readdir(adaptersDir);
30
+ const jsFiles = entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
31
+ await Promise.all(
32
+ jsFiles.map(async (file) => {
33
+ if (loaded.has(file)) return;
34
+ try {
35
+ const absolutePath = path.join(adaptersDir, file);
36
+ await import(/*turbopackIgnore: true*/ pathToFileURL(absolutePath).href);
37
+ loaded.add(file);
38
+ } catch {
39
+ // Malformed adapter — skip silently; operator needs to fix the file
40
+ }
41
+ })
42
+ );
43
+ } catch {
44
+ // adapters drop-zone missing or empty — normal for fresh upstream kit
45
+ }
46
+ }
47
+
48
+ async function listSandboxAdapterFiles() {
49
+ const adaptersDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/sandboxes/adapters");
50
+ try {
51
+ const entries = await fs.readdir(adaptersDir);
52
+ return entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ export { loadAllSandboxAdapters, listSandboxAdapterFiles };