@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.
- package/README.md +17 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +1349 -222
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1048 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1540 -433
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +141 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +211 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +126 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +16 -0
- package/dist/index.js +1764 -40677
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @growthub/cli
|
|
2
2
|
|
|
3
|
-
`@growthub/cli` is the
|
|
3
|
+
`@growthub/cli` is the local control plane for Growthub Local and Agent Workspace as Code (AWaC).
|
|
4
4
|
|
|
5
|
-
It
|
|
5
|
+
It turns repos, skills, starters, kits, and templates into governed **Workspaces** that can be exported, forked, inspected, operated by agents, kept current, and optionally connected to hosted authority. The Workspace is the top-level product object; the CLI is the executor that moves it through the lifecycle.
|
|
6
6
|
|
|
7
7
|
## Start here: create a governed Workspace
|
|
8
8
|
|
|
@@ -34,14 +34,26 @@ npm install -g @growthub/cli
|
|
|
34
34
|
|
|
35
35
|
Reference contracts: [Workspace Config Contract V1](../docs/WORKSPACE_CONFIG_CONTRACT_V1.md) · [Governed Workspace Topology V1](../docs/GOVERNED_WORKSPACE_TOPOLOGY_V1.md) · [Workspace Builder Runtime V1](../docs/WORKSPACE_BUILDER_RUNTIME_V1.md)
|
|
36
36
|
|
|
37
|
+
## CLI role in the governed workspace architecture
|
|
38
|
+
|
|
39
|
+
Growthub Local keeps the Workspace as the owned artifact: a forkable app, `growthub.config.json`, `.growthub-fork/` lifecycle state, builder state, agent-readable contracts, and optional hosted authority.
|
|
40
|
+
|
|
41
|
+
The CLI is the machine-readable path through that architecture:
|
|
42
|
+
|
|
43
|
+
- **Export** a starter, repo, skill, template, or worker kit into a local Workspace.
|
|
44
|
+
- **Register and inspect forks** so customization carries identity, policy, and trace instead of becoming an untracked copy.
|
|
45
|
+
- **Operate ongoing lifecycle checks** for workspace status, QA, deploy readiness, upstream drift, surface detection, and portal preparation.
|
|
46
|
+
- **Connect optional authority** through Growthub auth, bridge-backed integrations, hosted agents, and capability activation when local value is already clear.
|
|
47
|
+
- **Expose the same contracts to agents and humans** through structured commands, JSON output, skill manifests, helper scripts, and the Workspace Builder.
|
|
48
|
+
|
|
37
49
|
## Profile-first setup (recommended)
|
|
38
50
|
|
|
39
51
|
The guided flow is profile-first before deeper harness/workflow choices:
|
|
40
52
|
|
|
41
53
|
```bash
|
|
42
|
-
npm create growthub-local@latest -- --profile gtm
|
|
43
|
-
npm create growthub-local@latest -- --profile dx
|
|
44
|
-
npm create growthub-local@latest -- --profile workspace --out ./my-workspace
|
|
54
|
+
npm create @growthub/growthub-local@latest -- --profile gtm
|
|
55
|
+
npm create @growthub/growthub-local@latest -- --profile dx
|
|
56
|
+
npm create @growthub/growthub-local@latest -- --profile workspace --out ./my-workspace
|
|
45
57
|
```
|
|
46
58
|
|
|
47
59
|
## Discovery lanes
|
|
@@ -27,6 +27,33 @@ Use `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` when the deployed a
|
|
|
27
27
|
|
|
28
28
|
For first boot, the bundled app also supports a hybrid path: keep `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` and set `WINDSOR_API_KEY` locally. That overlays connected state for Windsor AI and Google Sheets blended data without moving the rest of the workspace off the hosted bridge authority path.
|
|
29
29
|
|
|
30
|
+
## Data sources and API registry
|
|
31
|
+
|
|
32
|
+
Use the Data Model page to configure API-backed Data Sources and reusable API Registry records. Credentials stay in workspace settings or server env; Data Model records store only non-secret references such as `authRef`.
|
|
33
|
+
|
|
34
|
+
See [`docs/data-sources-api-registry.md`](./docs/data-sources-api-registry.md) for the setup guide, test flow, widget source eligibility rules, and the LeadShark example.
|
|
35
|
+
|
|
36
|
+
## Integration resolvers
|
|
37
|
+
|
|
38
|
+
Drop one `.js` file per integration into `lib/adapters/integrations/resolvers/`. Each file registers a provider-agnostic resolver that the workspace routes (`test-source`, `refresh-sources`) dispatch to. The bridge confirms which integrations are connected — resolvers call the provider API directly using env-var tokens.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# List registered resolvers (JSON)
|
|
42
|
+
curl -s http://localhost:3000/api/workspace/resolvers
|
|
43
|
+
|
|
44
|
+
# Test a resolver before saving to data model (JSON)
|
|
45
|
+
curl -s -X POST http://localhost:3000/api/workspace/test-source \
|
|
46
|
+
-H "Content-Type: application/json" \
|
|
47
|
+
-d '{"integrationId":"google-analytics","binding":{"entityType":"ga4.traffic","sourceStorage":"workspace-source-records","sourceId":"ga4-traffic"}}'
|
|
48
|
+
|
|
49
|
+
# Register a resolver-backed data model object (persists to growthub.config.json)
|
|
50
|
+
curl -s -X PATCH http://localhost:3000/api/workspace \
|
|
51
|
+
-H "Content-Type: application/json" \
|
|
52
|
+
-d '{"dataModel":{"objects":[...]}}'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See [`lib/adapters/integrations/resolvers/README.md`](./lib/adapters/integrations/resolvers/README.md) for the full resolver shape, all CLI commands with JSON response contracts, and the complete data model → source dropdown → refresh flow.
|
|
56
|
+
|
|
30
57
|
## Run
|
|
31
58
|
|
|
32
59
|
```bash
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
* 400 { error: string }
|
|
17
17
|
*/
|
|
18
18
|
import { NextResponse } from "next/server";
|
|
19
|
-
import { listEntityMetadataForIntegration } from "@/lib/adapters/integrations";
|
|
19
|
+
import { listEntityMetadataForIntegration, listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
|
|
20
20
|
import { readAdapterConfig } from "@/lib/adapters/env";
|
|
21
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
22
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
21
23
|
|
|
22
24
|
async function GET(request) {
|
|
23
25
|
const { searchParams } = new URL(request.url);
|
|
@@ -30,21 +32,51 @@ async function GET(request) {
|
|
|
30
32
|
);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
const id = integrationId.trim();
|
|
33
36
|
const config = readAdapterConfig();
|
|
34
37
|
const isBridgeMode =
|
|
35
38
|
config.integrationAdapter === "growthub-bridge" &&
|
|
36
39
|
config.growthubBridge?.baseUrl &&
|
|
37
40
|
!!process.env.GROWTHUB_BRIDGE_ACCESS_TOKEN;
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
// 1. Try the Bridge / catalog path first
|
|
43
|
+
let entities = await listEntityMetadataForIntegration(id);
|
|
44
|
+
let source = entities.length ? "bridge" : "none";
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// 2. If Bridge returned nothing, fall back to resolver registry listEntities()
|
|
47
|
+
if (!entities.length) {
|
|
48
|
+
try {
|
|
49
|
+
await loadAllResolvers();
|
|
50
|
+
const resolver = getSourceResolver(id);
|
|
51
|
+
if (resolver && typeof resolver.listEntities === "function") {
|
|
52
|
+
const allConnections = await listGovernedWorkspaceIntegrations().catch(() => []);
|
|
53
|
+
const connection = allConnections.find((c) => c.provider === id || c.id === id) || null;
|
|
54
|
+
const raw = await resolver.listEntities(config, connection);
|
|
55
|
+
if (Array.isArray(raw) && raw.length) {
|
|
56
|
+
entities = raw.map((item) => ({
|
|
57
|
+
id: String(item.id || item.entityId || item.propertyId || item.accountId || ""),
|
|
58
|
+
label: String(item.label || item.name || item.displayName || item.id || ""),
|
|
59
|
+
secondaryLabel: item.secondaryLabel || item.domain || item.accountId || undefined,
|
|
60
|
+
entityType: item.entityType || item.type || undefined,
|
|
61
|
+
provider: id,
|
|
62
|
+
status: item.status || undefined,
|
|
63
|
+
metadata: item.metadata || undefined
|
|
64
|
+
})).filter((e) => e.id);
|
|
65
|
+
source = "resolver";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// resolver not available or threw — leave entities empty
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return NextResponse.json({
|
|
74
|
+
integrationId: id,
|
|
75
|
+
entities,
|
|
76
|
+
source,
|
|
77
|
+
requiresObjectResolver: entities.length === 0,
|
|
78
|
+
authority: isBridgeMode ? "growthub-bridge" : "local"
|
|
79
|
+
});
|
|
48
80
|
}
|
|
49
81
|
|
|
50
82
|
export { GET };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/workspace/list-entities?integrationId=<id>
|
|
3
|
+
*
|
|
4
|
+
* Calls the registered resolver's `listEntities()` function and returns a
|
|
5
|
+
* normalized entity list. Fully composable — no provider-specific logic here.
|
|
6
|
+
* The resolver file is the only place that knows what an "entity" means for its
|
|
7
|
+
* integration.
|
|
8
|
+
*
|
|
9
|
+
* Response (success):
|
|
10
|
+
* { entities: { id: string, label: string, meta?: object }[], hasListEntities: true }
|
|
11
|
+
*
|
|
12
|
+
* Response (resolver has no listEntities):
|
|
13
|
+
* { entities: [], hasListEntities: false }
|
|
14
|
+
*
|
|
15
|
+
* Response (resolver not found):
|
|
16
|
+
* { error: "no-resolver", integrationId } — 404
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { NextResponse } from "next/server";
|
|
20
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
21
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
22
|
+
|
|
23
|
+
async function GET(request) {
|
|
24
|
+
const { searchParams } = new URL(request.url);
|
|
25
|
+
const integrationId = searchParams.get("integrationId");
|
|
26
|
+
|
|
27
|
+
if (!integrationId) {
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ error: "integrationId query param required" },
|
|
30
|
+
{ status: 400 }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await loadAllResolvers();
|
|
35
|
+
const resolver = getSourceResolver(integrationId);
|
|
36
|
+
|
|
37
|
+
if (!resolver) {
|
|
38
|
+
return NextResponse.json(
|
|
39
|
+
{ error: "no-resolver", integrationId },
|
|
40
|
+
{ status: 404 }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof resolver.listEntities !== "function") {
|
|
45
|
+
return NextResponse.json({ entities: [], hasListEntities: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const entities = await resolver.listEntities({}, {});
|
|
50
|
+
return NextResponse.json({
|
|
51
|
+
entities: Array.isArray(entities) ? entities : [],
|
|
52
|
+
hasListEntities: true,
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{
|
|
57
|
+
error: err.message || "Failed to list entities",
|
|
58
|
+
reason: "list-error",
|
|
59
|
+
entities: [],
|
|
60
|
+
hasListEntities: true,
|
|
61
|
+
},
|
|
62
|
+
{ status: 500 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { GET };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/workspace/refresh-source
|
|
3
|
+
*
|
|
4
|
+
* Calls the registered resolver's `fetchRecords()` with the supplied binding,
|
|
5
|
+
* then persists the resulting rows into the specified data model object.
|
|
6
|
+
*
|
|
7
|
+
* Fully composable — the route has no knowledge of any specific provider.
|
|
8
|
+
* All provider logic lives in the resolver file.
|
|
9
|
+
*
|
|
10
|
+
* Request body:
|
|
11
|
+
* {
|
|
12
|
+
* integrationId: string, // matches resolver.integrationId
|
|
13
|
+
* binding: object, // forwarded verbatim to resolver.fetchRecords
|
|
14
|
+
* objectId: string | null // data model object to persist rows into
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Response (success):
|
|
18
|
+
* {
|
|
19
|
+
* ok: true,
|
|
20
|
+
* rowCount: number,
|
|
21
|
+
* columns: string[],
|
|
22
|
+
* objectId: string | null,
|
|
23
|
+
* persisted: boolean, // true when rows were written to growthub.config.json
|
|
24
|
+
* dataModel: object | null // full updated dataModel (for local state sync)
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* Response (failure):
|
|
28
|
+
* { ok: false, reason: string, error: string }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { NextResponse } from "next/server";
|
|
32
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
33
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
34
|
+
import { readWorkspaceConfig, writeWorkspaceConfig, describePersistenceMode } from "@/lib/workspace-config";
|
|
35
|
+
|
|
36
|
+
async function POST(request) {
|
|
37
|
+
let body;
|
|
38
|
+
try {
|
|
39
|
+
body = await request.json();
|
|
40
|
+
} catch {
|
|
41
|
+
return NextResponse.json({ ok: false, reason: "invalid-json" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { integrationId, binding, objectId } = body || {};
|
|
45
|
+
|
|
46
|
+
if (!integrationId) {
|
|
47
|
+
return NextResponse.json({ ok: false, reason: "integrationId required" }, { status: 400 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await loadAllResolvers();
|
|
51
|
+
const resolver = getSourceResolver(integrationId);
|
|
52
|
+
|
|
53
|
+
if (!resolver) {
|
|
54
|
+
return NextResponse.json(
|
|
55
|
+
{ ok: false, reason: "no-resolver", integrationId },
|
|
56
|
+
{ status: 404 }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let rows;
|
|
61
|
+
try {
|
|
62
|
+
rows = await resolver.fetchRecords({}, {}, binding || {});
|
|
63
|
+
if (!Array.isArray(rows)) rows = [];
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return NextResponse.json(
|
|
66
|
+
{ ok: false, reason: "fetch-error", error: err.message || "Resolver threw" },
|
|
67
|
+
{ status: 500 }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const columns = rows.length > 0
|
|
72
|
+
? Object.keys(rows[0])
|
|
73
|
+
: (Array.isArray(binding?.columns) ? binding.columns : []);
|
|
74
|
+
|
|
75
|
+
// Persist rows to the data model object when objectId is provided and FS writes are allowed
|
|
76
|
+
const persistence = describePersistenceMode();
|
|
77
|
+
let persisted = false;
|
|
78
|
+
let dataModel = null;
|
|
79
|
+
|
|
80
|
+
if (objectId && persistence.canSave) {
|
|
81
|
+
try {
|
|
82
|
+
const current = await readWorkspaceConfig();
|
|
83
|
+
const wc = current.workspaceConfig || current;
|
|
84
|
+
const existingObjects = Array.isArray(wc.dataModel?.objects) ? wc.dataModel.objects : [];
|
|
85
|
+
|
|
86
|
+
const updatedObjects = existingObjects.map((obj) => {
|
|
87
|
+
if (obj.id !== objectId) return obj;
|
|
88
|
+
return {
|
|
89
|
+
...obj,
|
|
90
|
+
columns: columns.length ? columns : obj.columns,
|
|
91
|
+
rows,
|
|
92
|
+
refreshedAt: new Date().toISOString(),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// If objectId wasn't found in the list, nothing to update but don't error
|
|
97
|
+
dataModel = { ...(wc.dataModel || {}), objects: updatedObjects };
|
|
98
|
+
|
|
99
|
+
await writeWorkspaceConfig({ dataModel });
|
|
100
|
+
persisted = true;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return NextResponse.json({
|
|
103
|
+
ok: true,
|
|
104
|
+
rowCount: rows.length,
|
|
105
|
+
columns,
|
|
106
|
+
objectId: objectId || null,
|
|
107
|
+
persisted: false,
|
|
108
|
+
persistError: err.message,
|
|
109
|
+
dataModel: null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return NextResponse.json({
|
|
115
|
+
ok: true,
|
|
116
|
+
rowCount: rows.length,
|
|
117
|
+
columns,
|
|
118
|
+
objectId: objectId || null,
|
|
119
|
+
persisted,
|
|
120
|
+
dataModel,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { POST };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/workspace/refresh-sources
|
|
3
|
+
*
|
|
4
|
+
* Thin dispatcher for live-backed data sources. Zero provider-specific logic
|
|
5
|
+
* lives here. Each integration ships its own resolver file under
|
|
6
|
+
* `lib/adapters/integrations/resolvers/<id>.js` and registers itself via
|
|
7
|
+
* `registerSourceResolver()` from source-resolver-registry.js.
|
|
8
|
+
*
|
|
9
|
+
* This route never imports any provider directly. It reads the registry and
|
|
10
|
+
* dispatches to whatever resolvers the operator has registered. An upstream
|
|
11
|
+
* kit with no resolver files registered still works — it just skips sources
|
|
12
|
+
* that have no resolver and returns them in the `skipped` array.
|
|
13
|
+
*
|
|
14
|
+
* Request body:
|
|
15
|
+
* { sourceIds: string[] } — IDs of dataModel.objects to refresh
|
|
16
|
+
*
|
|
17
|
+
* Response shape:
|
|
18
|
+
* 200 { refreshed: SourceRefreshResult[], skipped: string[] }
|
|
19
|
+
* 400 { error: string }
|
|
20
|
+
* 500 { error: string }
|
|
21
|
+
*
|
|
22
|
+
* SourceRefreshResult:
|
|
23
|
+
* { sourceId: string, integrationId: string, recordCount: number, fetchedAt: string }
|
|
24
|
+
*
|
|
25
|
+
* Authority contract (GOVERNED_WORKSPACE_TOPOLOGY_V1):
|
|
26
|
+
* - Browser sends only non-secret sourceIds. No tokens, no provider auth.
|
|
27
|
+
* - Server reads env credentials via readAdapterConfig().
|
|
28
|
+
* - Normalized records are persisted via writeWorkspaceSourceRecords().
|
|
29
|
+
* - Raw provider payloads are never forwarded to the client.
|
|
30
|
+
* - The Growthub bridge owns provider auth — this route never holds tokens.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { NextResponse } from "next/server";
|
|
34
|
+
import { readAdapterConfig } from "@/lib/adapters/env";
|
|
35
|
+
import { listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
|
|
36
|
+
import { readWorkspaceConfig, writeWorkspaceSourceRecords } from "@/lib/workspace-config";
|
|
37
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
38
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
39
|
+
|
|
40
|
+
async function POST(request) {
|
|
41
|
+
let body;
|
|
42
|
+
try {
|
|
43
|
+
body = await request.json();
|
|
44
|
+
} catch {
|
|
45
|
+
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
49
|
+
return NextResponse.json({ error: "body must be a plain object" }, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { sourceIds } = body;
|
|
53
|
+
if (!Array.isArray(sourceIds) || sourceIds.length === 0) {
|
|
54
|
+
return NextResponse.json({ error: "sourceIds must be a non-empty array" }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const invalidIds = sourceIds.filter((id) => typeof id !== "string" || !id.trim());
|
|
58
|
+
if (invalidIds.length) {
|
|
59
|
+
return NextResponse.json({ error: "every sourceId must be a non-empty string" }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let workspaceConfig;
|
|
63
|
+
try {
|
|
64
|
+
workspaceConfig = await readWorkspaceConfig();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return NextResponse.json({ error: `failed to read workspace config: ${err.message}` }, { status: 500 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await loadAllResolvers();
|
|
70
|
+
|
|
71
|
+
const adapterConfig = readAdapterConfig();
|
|
72
|
+
const dataObjects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
|
|
73
|
+
|
|
74
|
+
// Resolve bridge connections once for this batch so every resolver
|
|
75
|
+
// receives the full live connection object (connectionId, authPath, metadata).
|
|
76
|
+
let bridgeIntegrations = [];
|
|
77
|
+
try {
|
|
78
|
+
bridgeIntegrations = await listGovernedWorkspaceIntegrations();
|
|
79
|
+
} catch {
|
|
80
|
+
// Non-fatal — resolvers fall back to env-only auth
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const refreshed = [];
|
|
84
|
+
const skipped = [];
|
|
85
|
+
|
|
86
|
+
for (const sourceId of sourceIds) {
|
|
87
|
+
const obj = dataObjects.find((o) => o.id === sourceId);
|
|
88
|
+
if (!obj) {
|
|
89
|
+
skipped.push(sourceId);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const binding = obj.binding;
|
|
94
|
+
if (!binding || binding.sourceStorage !== "workspace-source-records") {
|
|
95
|
+
skipped.push(sourceId);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const integrationId = binding.integrationId;
|
|
100
|
+
if (!integrationId) {
|
|
101
|
+
skipped.push(sourceId);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const resolver = getSourceResolver(integrationId);
|
|
106
|
+
if (!resolver) {
|
|
107
|
+
skipped.push(sourceId);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const connection = bridgeIntegrations.find(
|
|
113
|
+
(i) => i.provider === integrationId || i.id === integrationId
|
|
114
|
+
) || null;
|
|
115
|
+
const records = await resolver.fetchRecords(adapterConfig, connection, binding);
|
|
116
|
+
const fetchedAt = new Date().toISOString();
|
|
117
|
+
await writeWorkspaceSourceRecords(sourceId, records, { integrationId, fetchedAt });
|
|
118
|
+
refreshed.push({ sourceId, integrationId, recordCount: records.length, fetchedAt });
|
|
119
|
+
} catch {
|
|
120
|
+
skipped.push(sourceId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return NextResponse.json({ refreshed, skipped });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { POST };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/workspace/register-resolver
|
|
3
|
+
*
|
|
4
|
+
* Accepts a `.js` resolver file upload and saves it to
|
|
5
|
+
* `lib/adapters/integrations/resolvers/<filename>.js`.
|
|
6
|
+
*
|
|
7
|
+
* Only available in filesystem mode (local development with
|
|
8
|
+
* WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or NODE_ENV=development).
|
|
9
|
+
* In read-only runtimes this returns 409 with guidance.
|
|
10
|
+
*
|
|
11
|
+
* The uploaded file must:
|
|
12
|
+
* - be a valid UTF-8 text JavaScript module
|
|
13
|
+
* - call registerSourceResolver() at module level
|
|
14
|
+
* - have a .js extension
|
|
15
|
+
*
|
|
16
|
+
* After upload, call POST /api/workspace/test-source to verify the resolver
|
|
17
|
+
* registers correctly and fetchRecords returns well-formed records.
|
|
18
|
+
*
|
|
19
|
+
* Request: multipart/form-data
|
|
20
|
+
* file — the .js resolver file
|
|
21
|
+
* filename — optional override for the saved filename (slug.js)
|
|
22
|
+
*
|
|
23
|
+
* Response:
|
|
24
|
+
* 201 { saved: true, filename, path }
|
|
25
|
+
* 400 { error }
|
|
26
|
+
* 409 { error, guidance }
|
|
27
|
+
* 500 { error }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { NextResponse } from "next/server";
|
|
31
|
+
import { promises as fs } from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { describePersistenceMode } from "@/lib/workspace-config";
|
|
34
|
+
|
|
35
|
+
const MAX_RESOLVER_SIZE = 256 * 1024; // 256 KB — resolvers should be small
|
|
36
|
+
|
|
37
|
+
function slugify(name) {
|
|
38
|
+
return name
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/\.js$/, "")
|
|
41
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
42
|
+
.replace(/-+/g, "-")
|
|
43
|
+
.replace(/^-|-$/g, "")
|
|
44
|
+
.slice(0, 64) || "resolver";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function POST(request) {
|
|
48
|
+
const persistence = describePersistenceMode();
|
|
49
|
+
if (!persistence.canSave) {
|
|
50
|
+
return NextResponse.json({
|
|
51
|
+
error: "resolver upload requires a writable filesystem runtime",
|
|
52
|
+
guidance: persistence.guidance || "Set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or use local Next.js development mode."
|
|
53
|
+
}, { status: 409 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let formData;
|
|
57
|
+
try {
|
|
58
|
+
formData = await request.formData();
|
|
59
|
+
} catch {
|
|
60
|
+
return NextResponse.json({ error: "expected multipart/form-data with a file field" }, { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const file = formData.get("file");
|
|
64
|
+
const filenameOverride = formData.get("filename");
|
|
65
|
+
|
|
66
|
+
if (!file || typeof file.text !== "function") {
|
|
67
|
+
return NextResponse.json({ error: "file field is required" }, { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const originalName = typeof file.name === "string" ? file.name : "resolver.js";
|
|
71
|
+
if (!originalName.endsWith(".js")) {
|
|
72
|
+
return NextResponse.json({ error: "resolver file must have a .js extension" }, { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (file.size > MAX_RESOLVER_SIZE) {
|
|
76
|
+
return NextResponse.json({ error: `resolver file must be smaller than ${MAX_RESOLVER_SIZE / 1024} KB` }, { status: 400 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = await file.text();
|
|
82
|
+
} catch {
|
|
83
|
+
return NextResponse.json({ error: "could not read uploaded file" }, { status: 400 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!text.includes("registerSourceResolver")) {
|
|
87
|
+
return NextResponse.json({
|
|
88
|
+
error: "resolver file must call registerSourceResolver() — see lib/adapters/integrations/resolvers/README.md for the required shape"
|
|
89
|
+
}, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rawName = typeof filenameOverride === "string" && filenameOverride.trim()
|
|
93
|
+
? filenameOverride.trim()
|
|
94
|
+
: originalName;
|
|
95
|
+
const filename = `${slugify(rawName)}.js`;
|
|
96
|
+
|
|
97
|
+
const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/integrations/resolvers");
|
|
98
|
+
const outPath = path.join(resolversDir, filename);
|
|
99
|
+
|
|
100
|
+
if (path.dirname(outPath) !== resolversDir) {
|
|
101
|
+
return NextResponse.json({ error: "invalid filename — path traversal not allowed" }, { status: 400 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await fs.mkdir(resolversDir, { recursive: true });
|
|
106
|
+
await fs.writeFile(outPath, text, "utf8");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return NextResponse.json({ error: `failed to write resolver file: ${err.message}` }, { status: 500 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return NextResponse.json({
|
|
112
|
+
saved: true,
|
|
113
|
+
filename,
|
|
114
|
+
path: `lib/adapters/integrations/resolvers/${filename}`,
|
|
115
|
+
hint: "Run POST /api/workspace/test-source with integrationId to verify the resolver registers and fetchRecords works."
|
|
116
|
+
}, { status: 201 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { POST };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/workspace/resolvers
|
|
3
|
+
*
|
|
4
|
+
* Lists resolver files present in lib/adapters/integrations/resolvers/ and
|
|
5
|
+
* returns provider-agnostic metadata for each registered resolver.
|
|
6
|
+
* Used by the generic resolver management panel and ResolverControlPanel in the
|
|
7
|
+
* widget inspector. No provider names appear in the response shape.
|
|
8
|
+
*
|
|
9
|
+
* Response:
|
|
10
|
+
* {
|
|
11
|
+
* files: string[],
|
|
12
|
+
* registeredIds: string[],
|
|
13
|
+
* resolvers: {
|
|
14
|
+
* integrationId: string,
|
|
15
|
+
* entityTypes: string[],
|
|
16
|
+
* hasListEntities: boolean,
|
|
17
|
+
* configSchema: SchemaField[] | null
|
|
18
|
+
* }[],
|
|
19
|
+
* canUpload: boolean
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { NextResponse } from "next/server";
|
|
24
|
+
import { loadAllResolvers, listResolverFiles } from "@/lib/adapters/integrations/resolver-loader";
|
|
25
|
+
import { describeRegisteredResolvers } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
26
|
+
import { describePersistenceMode } from "@/lib/workspace-config";
|
|
27
|
+
|
|
28
|
+
async function GET() {
|
|
29
|
+
await loadAllResolvers();
|
|
30
|
+
const files = await listResolverFiles();
|
|
31
|
+
const resolvers = describeRegisteredResolvers();
|
|
32
|
+
const persistence = describePersistenceMode();
|
|
33
|
+
return NextResponse.json({
|
|
34
|
+
files,
|
|
35
|
+
registeredIds: resolvers.map((r) => r.integrationId),
|
|
36
|
+
resolvers,
|
|
37
|
+
canUpload: persistence.canSave
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { GET };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/workspace/sandbox-adapters
|
|
3
|
+
*
|
|
4
|
+
* Lists every registered sandbox adapter — the default `local-process`
|
|
5
|
+
* shipped at `lib/adapters/sandboxes/default-local-process.js` plus any
|
|
6
|
+
* drop-zone adapter file added under `lib/adapters/sandboxes/adapters/`.
|
|
7
|
+
*
|
|
8
|
+
* Used by the Data Model drawer's adapter dropdown for the
|
|
9
|
+
* `sandbox-environment` object type. Returns provider-agnostic metadata only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { NextResponse } from "next/server";
|
|
13
|
+
import { describeRegisteredSandboxAdapters, ensureSandboxAdaptersLoaded } from "@/lib/adapters/sandboxes";
|
|
14
|
+
|
|
15
|
+
async function GET() {
|
|
16
|
+
await ensureSandboxAdaptersLoaded();
|
|
17
|
+
const adapters = describeRegisteredSandboxAdapters();
|
|
18
|
+
return NextResponse.json({ adapters });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { GET };
|