forgeos 0.1.0-alpha.32 → 0.1.0-alpha.34
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/AGENTS.md +1 -1
- package/CHANGELOG.md +23 -0
- package/docs/changelog.md +33 -0
- package/package.json +1 -1
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/cli/changed.ts +102 -8
- package/src/forge/cli/commands.ts +116 -4
- package/src/forge/cli/dev.ts +228 -0
- package/src/forge/cli/handoff.ts +17 -0
- package/src/forge/cli/main.ts +36 -0
- package/src/forge/cli/new.ts +4 -0
- package/src/forge/cli/parse.ts +24 -6
- package/src/forge/cli/workos.ts +250 -23
- package/src/forge/compiler/app-graph/build.ts +44 -1
- package/src/forge/compiler/client-sdk/render-client.ts +31 -0
- package/src/forge/compiler/data-graph/parse.ts +17 -1
- package/src/forge/compiler/data-graph/sql/ddl.ts +24 -13
- package/src/forge/compiler/diagnostics/codes.ts +6 -0
- package/src/forge/compiler/diagnostics/create.ts +14 -0
- package/src/forge/compiler/integration/plan.ts +6 -0
- package/src/forge/compiler/integration/render.ts +9 -0
- package/src/forge/compiler/integration/templates/render.ts +2 -0
- package/src/forge/compiler/integration/templates/types.ts +3 -0
- package/src/forge/compiler/integration/templates/workos.ts +165 -99
- package/src/forge/compiler/orchestrator/plan.ts +7 -0
- package/src/forge/delta/status.ts +10 -1
- package/src/forge/impact/index.ts +158 -1
- package/src/forge/impact/types.ts +27 -1
- package/src/forge/react/index.ts +19 -2
- package/src/forge/runtime/auth/resolve.ts +33 -3
- package/src/forge/runtime/auth/types.ts +1 -0
- package/src/forge/server.ts +5 -1
- package/src/forge/version.ts +1 -1
- package/src/forge/vue/index.ts +16 -1
- package/src/forge/workspace/git-summary.ts +1 -1
- package/templates/nuxt-web/web/package.json +2 -1
- package/templates/nuxt-web/web/tsconfig.json +4 -1
package/src/forge/cli/workos.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface WorkOSCommandResult {
|
|
|
28
28
|
checks: WorkOSCheck[];
|
|
29
29
|
command?: string[];
|
|
30
30
|
applied?: boolean;
|
|
31
|
+
data?: unknown;
|
|
31
32
|
stdout?: string;
|
|
32
33
|
stderr?: string;
|
|
33
34
|
exitCode: 0 | 1;
|
|
@@ -87,6 +88,180 @@ function includesAll(haystack: string, needles: string[]): boolean {
|
|
|
87
88
|
return needles.every((needle) => haystack.includes(needle));
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function uniqueSorted(values: Iterable<string>): string[] {
|
|
92
|
+
return [...new Set([...values].filter(Boolean))].sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function quotedValues(text: string): string[] {
|
|
96
|
+
return [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]!).filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface WorkOSSeedSummary {
|
|
100
|
+
exists: boolean;
|
|
101
|
+
valid: boolean;
|
|
102
|
+
path: string;
|
|
103
|
+
permissions: string[];
|
|
104
|
+
roles: string[];
|
|
105
|
+
resourceTypes: string[];
|
|
106
|
+
organizations: string[];
|
|
107
|
+
domains: string[];
|
|
108
|
+
diagnostics: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseSlug(line: string): string | null {
|
|
112
|
+
const match = /(?:^|\s)-?\s*slug:\s*["']?([^"',\]\s]+)["']?/.exec(line);
|
|
113
|
+
return match?.[1] ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseName(line: string): string | null {
|
|
117
|
+
const match = /(?:^|\s)-?\s*name:\s*["']?([^"'\]]+(?:\s[^"'\]]+)*)["']?/.exec(line);
|
|
118
|
+
return match?.[1]?.trim() ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEED_FILE): WorkOSSeedSummary {
|
|
122
|
+
const seedPath = exists(workspaceRoot, preferredPath)
|
|
123
|
+
? preferredPath
|
|
124
|
+
: exists(workspaceRoot, GENERATED_SEED_FILE)
|
|
125
|
+
? GENERATED_SEED_FILE
|
|
126
|
+
: preferredPath;
|
|
127
|
+
const raw = readText(workspaceRoot, seedPath);
|
|
128
|
+
const permissions = new Set<string>();
|
|
129
|
+
const roles = new Set<string>();
|
|
130
|
+
const resourceTypes = new Set<string>();
|
|
131
|
+
const organizations = new Set<string>();
|
|
132
|
+
const domains = new Set<string>();
|
|
133
|
+
const diagnostics: string[] = [];
|
|
134
|
+
let section = "";
|
|
135
|
+
|
|
136
|
+
if (!raw.trim()) {
|
|
137
|
+
return {
|
|
138
|
+
exists: false,
|
|
139
|
+
valid: false,
|
|
140
|
+
path: seedPath,
|
|
141
|
+
permissions: [],
|
|
142
|
+
roles: [],
|
|
143
|
+
resourceTypes: [],
|
|
144
|
+
organizations: [],
|
|
145
|
+
domains: [],
|
|
146
|
+
diagnostics: [`${seedPath} is missing or empty`],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
151
|
+
const rootSection = /^([a-zA-Z_][\w-]*):\s*$/.exec(line);
|
|
152
|
+
if (rootSection) {
|
|
153
|
+
section = rootSection[1]!;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const slug = parseSlug(line);
|
|
158
|
+
if (slug) {
|
|
159
|
+
if (section === "permissions") {
|
|
160
|
+
permissions.add(slug);
|
|
161
|
+
} else if (section === "roles") {
|
|
162
|
+
roles.add(slug);
|
|
163
|
+
} else if (section === "resource_types") {
|
|
164
|
+
resourceTypes.add(slug);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (section === "organizations") {
|
|
169
|
+
const name = parseName(line);
|
|
170
|
+
if (name) {
|
|
171
|
+
organizations.add(name);
|
|
172
|
+
}
|
|
173
|
+
for (const value of quotedValues(line)) {
|
|
174
|
+
if (value.includes(".")) {
|
|
175
|
+
domains.add(value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const match of line.matchAll(/-\s*["']?([a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+)["']?/g)) {
|
|
181
|
+
permissions.add(match[1]!);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (permissions.size === 0) diagnostics.push("no permission slugs found");
|
|
186
|
+
if (roles.size === 0) diagnostics.push("no role slugs found");
|
|
187
|
+
if (resourceTypes.size === 0) diagnostics.push("no resource_types slugs found");
|
|
188
|
+
if (organizations.size === 0) diagnostics.push("no organizations found");
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
exists: true,
|
|
192
|
+
valid: diagnostics.length === 0,
|
|
193
|
+
path: seedPath,
|
|
194
|
+
permissions: uniqueSorted(permissions),
|
|
195
|
+
roles: uniqueSorted(roles),
|
|
196
|
+
resourceTypes: uniqueSorted(resourceTypes),
|
|
197
|
+
organizations: uniqueSorted(organizations),
|
|
198
|
+
domains: uniqueSorted(domains),
|
|
199
|
+
diagnostics,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectPolicyPermissions(workspaceRoot: string): string[] {
|
|
204
|
+
const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
|
|
205
|
+
policies?: Array<{ permissions?: string[] }>;
|
|
206
|
+
} | null;
|
|
207
|
+
const permissions = new Set<string>();
|
|
208
|
+
for (const policy of registry?.policies ?? []) {
|
|
209
|
+
for (const permission of policy.permissions ?? []) {
|
|
210
|
+
permissions.add(permission);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const path of ["src/policies.ts", "src/policies.workos.ts"]) {
|
|
214
|
+
const text = readText(workspaceRoot, path);
|
|
215
|
+
for (const match of text.matchAll(/canPermission\s*\(([^)]*)\)/g)) {
|
|
216
|
+
for (const value of quotedValues(match[1] ?? "")) {
|
|
217
|
+
permissions.add(value);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return uniqueSorted(permissions);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function singularResourceName(name: string): string {
|
|
225
|
+
if (name.endsWith("ies")) {
|
|
226
|
+
return `${name.slice(0, -3)}y`;
|
|
227
|
+
}
|
|
228
|
+
if (name.endsWith("ses")) {
|
|
229
|
+
return name.slice(0, -2);
|
|
230
|
+
}
|
|
231
|
+
if (name.endsWith("s") && name.length > 1) {
|
|
232
|
+
return name.slice(0, -1);
|
|
233
|
+
}
|
|
234
|
+
return name;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function collectExpectedResourceTypes(workspaceRoot: string): string[] {
|
|
238
|
+
const dataGraph = readJson(workspaceRoot, `${GENERATED_DIR}/dataGraph.json`) as {
|
|
239
|
+
tables?: Array<{ name?: string; fields?: Array<{ name?: string }> }>;
|
|
240
|
+
} | null;
|
|
241
|
+
const agentContract = readJson(workspaceRoot, `${GENERATED_DIR}/agentContract.json`) as {
|
|
242
|
+
auth?: { requiresTenant?: boolean };
|
|
243
|
+
} | null;
|
|
244
|
+
const resourceTypes = new Set<string>();
|
|
245
|
+
const tenantScopedTables = (dataGraph?.tables ?? []).filter((table) =>
|
|
246
|
+
(table.fields ?? []).some((field) => field.name === "tenantId")
|
|
247
|
+
);
|
|
248
|
+
if (agentContract?.auth?.requiresTenant || tenantScopedTables.length > 0) {
|
|
249
|
+
resourceTypes.add("organization");
|
|
250
|
+
}
|
|
251
|
+
for (const table of tenantScopedTables) {
|
|
252
|
+
if (!table.name || ["organizations", "organization", "memberships", "membership"].includes(table.name)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
resourceTypes.add(singularResourceName(table.name));
|
|
256
|
+
}
|
|
257
|
+
return uniqueSorted(resourceTypes);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function missingValues(expected: string[], actual: string[]): string[] {
|
|
261
|
+
const actualSet = new Set(actual);
|
|
262
|
+
return expected.filter((value) => !actualSet.has(value));
|
|
263
|
+
}
|
|
264
|
+
|
|
90
265
|
function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
91
266
|
const packageJson = readJson(workspaceRoot, "package.json") as {
|
|
92
267
|
dependencies?: Record<string, string>;
|
|
@@ -103,7 +278,11 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
|
103
278
|
...(packageJson?.dependencies ?? {}),
|
|
104
279
|
...(packageJson?.devDependencies ?? {}),
|
|
105
280
|
};
|
|
106
|
-
const
|
|
281
|
+
const seed = parseSeedFile(workspaceRoot);
|
|
282
|
+
const activePermissions = collectPolicyPermissions(workspaceRoot);
|
|
283
|
+
const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
|
|
284
|
+
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
285
|
+
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
107
286
|
const authRoutes = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/auth-routes.ts`);
|
|
108
287
|
const fga = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/fga.ts`);
|
|
109
288
|
const resourceMap = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/resource-map.ts`);
|
|
@@ -135,30 +314,33 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
|
135
314
|
},
|
|
136
315
|
{
|
|
137
316
|
name: "seed-file",
|
|
138
|
-
ok: exists
|
|
139
|
-
detail:
|
|
317
|
+
ok: seed.exists,
|
|
318
|
+
detail: seed.exists
|
|
319
|
+
? `${seed.path} exists with ${seed.permissions.length} permission(s), ${seed.roles.length} role(s), ${seed.resourceTypes.length} resource type(s)`
|
|
320
|
+
: `${DEFAULT_SEED_FILE} or ${GENERATED_SEED_FILE} is required`,
|
|
140
321
|
},
|
|
141
322
|
{
|
|
142
323
|
name: "seed-organizations",
|
|
143
|
-
ok:
|
|
144
|
-
detail:
|
|
324
|
+
ok: seed.organizations.length > 0,
|
|
325
|
+
detail: seed.organizations.length > 0
|
|
326
|
+
? `seed file contains organization(s): ${seed.organizations.join(", ")}`
|
|
327
|
+
: "seed file should contain at least one demo organization",
|
|
145
328
|
},
|
|
146
329
|
{
|
|
147
330
|
name: "seed-roles-permissions",
|
|
148
|
-
ok:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"
|
|
152
|
-
|
|
153
|
-
"invitations:create",
|
|
154
|
-
"tasks:update",
|
|
155
|
-
]),
|
|
156
|
-
detail: "seed file contains owner/manager/member roles and onboarding permissions",
|
|
331
|
+
ok: seed.roles.length > 0 &&
|
|
332
|
+
(activePermissions.length === 0 ? seed.permissions.length > 0 : missingSeedPermissions.length === 0),
|
|
333
|
+
detail: missingSeedPermissions.length === 0
|
|
334
|
+
? `seed covers ${activePermissions.length} active policy permission(s) with role(s): ${seed.roles.join(", ")}`
|
|
335
|
+
: `seed missing active policy permission(s): ${missingSeedPermissions.join(", ")}`,
|
|
157
336
|
},
|
|
158
337
|
{
|
|
159
338
|
name: "seed-resource-types",
|
|
160
|
-
ok:
|
|
161
|
-
|
|
339
|
+
ok: seed.resourceTypes.length > 0 &&
|
|
340
|
+
(expectedResourceTypes.length === 0 || missingSeedResources.length === 0),
|
|
341
|
+
detail: missingSeedResources.length === 0
|
|
342
|
+
? `seed resource_types cover app graph: ${expectedResourceTypes.length > 0 ? expectedResourceTypes.join(", ") : seed.resourceTypes.join(", ")}`
|
|
343
|
+
: `seed missing resource_type(s) for app graph: ${missingSeedResources.join(", ")}`,
|
|
162
344
|
},
|
|
163
345
|
{
|
|
164
346
|
name: "authkit-routes",
|
|
@@ -192,14 +374,17 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
|
|
|
192
374
|
"permissionSlug",
|
|
193
375
|
"resourceExternalId",
|
|
194
376
|
]) &&
|
|
195
|
-
includesAll(fga, ["forgeWorkOSResourceTypes"
|
|
377
|
+
includesAll(fga, ["forgeWorkOSResourceTypes"]),
|
|
196
378
|
detail: "WorkOS FGA resource-map bridge exists with sync, cache, telemetry, official check shape, and cross-tenant guard",
|
|
197
379
|
},
|
|
198
380
|
{
|
|
199
381
|
name: "policies",
|
|
200
382
|
ok: exists(workspaceRoot, "src/policies.workos.ts") &&
|
|
201
|
-
|
|
202
|
-
|
|
383
|
+
policies.includes("canPermission") &&
|
|
384
|
+
missingValues(activePermissions, quotedValues(policies)).length === 0,
|
|
385
|
+
detail: activePermissions.length === 0
|
|
386
|
+
? "WorkOS-derived Forge policy template exists and is permission-first"
|
|
387
|
+
: `WorkOS-derived policy template covers active permission(s): ${activePermissions.join(", ")}`,
|
|
203
388
|
},
|
|
204
389
|
];
|
|
205
390
|
}
|
|
@@ -280,19 +465,60 @@ export function runWorkOSInstallCommand(options: WorkOSCommandOptions): WorkOSCo
|
|
|
280
465
|
|
|
281
466
|
export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
282
467
|
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
468
|
+
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
469
|
+
const activePermissions = collectPolicyPermissions(options.workspaceRoot);
|
|
470
|
+
const expectedResourceTypes = collectExpectedResourceTypes(options.workspaceRoot);
|
|
471
|
+
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
472
|
+
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
473
|
+
const unusedSeedPermissions = seed.permissions.filter((permission) => !activePermissions.includes(permission));
|
|
283
474
|
const checks = [
|
|
284
475
|
{
|
|
285
476
|
name: "seed-file",
|
|
286
|
-
ok: exists
|
|
287
|
-
detail: `${file} exists`,
|
|
477
|
+
ok: seed.exists,
|
|
478
|
+
detail: seed.exists ? `${seed.path} exists` : `${file} exists`,
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
name: "seed-yaml-shape",
|
|
482
|
+
ok: seed.valid,
|
|
483
|
+
detail: seed.valid ? "seed contains permissions, roles, resource_types, and organizations" : seed.diagnostics.join("; "),
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
name: "seed-policy-coverage",
|
|
487
|
+
ok: missingSeedPermissions.length === 0,
|
|
488
|
+
detail: missingSeedPermissions.length === 0
|
|
489
|
+
? `seed covers ${activePermissions.length} active permission(s)`
|
|
490
|
+
: `seed missing active permission(s): ${missingSeedPermissions.join(", ")}`,
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: "seed-resource-coverage",
|
|
494
|
+
ok: missingSeedResources.length === 0,
|
|
495
|
+
detail: missingSeedResources.length === 0
|
|
496
|
+
? `seed covers app resource type(s): ${expectedResourceTypes.join(", ") || "none required"}`
|
|
497
|
+
: `seed missing resource type(s): ${missingSeedResources.join(", ")}`,
|
|
288
498
|
},
|
|
289
499
|
];
|
|
290
500
|
const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
|
|
291
501
|
if (!checks.every((check) => check.ok)) {
|
|
292
|
-
return {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
kind: "workos-seed",
|
|
505
|
+
checks,
|
|
506
|
+
command,
|
|
507
|
+
applied: false,
|
|
508
|
+
data: { seed, activePermissions, expectedResourceTypes, unusedSeedPermissions },
|
|
509
|
+
exitCode: 1,
|
|
510
|
+
};
|
|
293
511
|
}
|
|
294
512
|
if (!options.yes || options.dryRun) {
|
|
295
|
-
return {
|
|
513
|
+
return {
|
|
514
|
+
ok: true,
|
|
515
|
+
kind: "workos-seed",
|
|
516
|
+
checks,
|
|
517
|
+
command,
|
|
518
|
+
applied: false,
|
|
519
|
+
data: { seed, activePermissions, expectedResourceTypes, unusedSeedPermissions },
|
|
520
|
+
exitCode: 0,
|
|
521
|
+
};
|
|
296
522
|
}
|
|
297
523
|
const child = runExternalCommand(command, options);
|
|
298
524
|
return {
|
|
@@ -301,6 +527,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
301
527
|
checks,
|
|
302
528
|
command,
|
|
303
529
|
applied: child.status === 0,
|
|
530
|
+
data: { seed, activePermissions, expectedResourceTypes, unusedSeedPermissions },
|
|
304
531
|
stdout: child.stdout,
|
|
305
532
|
stderr: child.stderr,
|
|
306
533
|
exitCode: child.status === 0 ? 0 : 1,
|
|
@@ -3,6 +3,8 @@ import { hashStable } from "../primitives/hash.ts";
|
|
|
3
3
|
import { normalizePath } from "../primitives/paths.ts";
|
|
4
4
|
import { stableSortEdges, stableSortSymbols } from "../primitives/sort.ts";
|
|
5
5
|
import type { AppGraph, ForgeEdge, ForgeSymbol, SourceFile } from "../types/app-graph.ts";
|
|
6
|
+
import { FORGE_RUNTIME_EXPORT_NAME_REQUIRED } from "../diagnostics/codes.ts";
|
|
7
|
+
import { createDiagnostic } from "../diagnostics/create.ts";
|
|
6
8
|
import { detectDuplicateSymbols } from "./dup-symbol.ts";
|
|
7
9
|
import { buildModuleGraph } from "./module-graph.ts";
|
|
8
10
|
import { incrementalParse } from "./parser.ts";
|
|
@@ -97,6 +99,46 @@ function buildAppGraphEdges(
|
|
|
97
99
|
return edges;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
const NAMED_EXPORT_REQUIRED_KINDS = new Set([
|
|
103
|
+
"command",
|
|
104
|
+
"query",
|
|
105
|
+
"liveQuery",
|
|
106
|
+
"action",
|
|
107
|
+
"workflow",
|
|
108
|
+
"endpoint",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
function detectUnnamedForgeExports(symbols: ForgeSymbol[]): AppGraph["diagnostics"] {
|
|
112
|
+
return symbols
|
|
113
|
+
.filter((symbol) =>
|
|
114
|
+
NAMED_EXPORT_REQUIRED_KINDS.has(symbol.kind) &&
|
|
115
|
+
symbol.meta.exportPath === "default"
|
|
116
|
+
)
|
|
117
|
+
.map((symbol) =>
|
|
118
|
+
createDiagnostic({
|
|
119
|
+
severity: "error",
|
|
120
|
+
code: FORGE_RUNTIME_EXPORT_NAME_REQUIRED,
|
|
121
|
+
message:
|
|
122
|
+
`Forge ${symbol.kind} in ${symbol.file} uses export default; runtime entries must be named exports for stable API names.`,
|
|
123
|
+
file: symbol.file,
|
|
124
|
+
span: symbol.span,
|
|
125
|
+
fixHint:
|
|
126
|
+
`Replace \`export default ${symbol.kind}(...)\` with \`export const ${suggestExportName(symbol)} = ${symbol.kind}(...)\`.`,
|
|
127
|
+
suggestedCommands: ["forge check --json", "forge generate"],
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function suggestExportName(symbol: ForgeSymbol): string {
|
|
133
|
+
const base = symbol.file
|
|
134
|
+
.split("/")
|
|
135
|
+
.pop()
|
|
136
|
+
?.replace(/\.[cm]?[jt]sx?$/, "")
|
|
137
|
+
.replace(/[^a-zA-Z0-9]+(.)/g, (_, next: string) => next.toUpperCase())
|
|
138
|
+
.replace(/^[^a-zA-Z_]+/, "") || symbol.kind;
|
|
139
|
+
return base || symbol.kind;
|
|
140
|
+
}
|
|
141
|
+
|
|
100
142
|
export async function buildAppGraph(
|
|
101
143
|
options: AppGraphBuildOptions,
|
|
102
144
|
): Promise<AppGraph> {
|
|
@@ -141,6 +183,7 @@ export async function buildAppGraph(
|
|
|
141
183
|
const symbolsMs = Date.now() - checkpoint;
|
|
142
184
|
checkpoint = Date.now();
|
|
143
185
|
const dupDiagnostics = detectDuplicateSymbols(forgeSymbols);
|
|
186
|
+
const unnamedExportDiagnostics = detectUnnamedForgeExports(forgeSymbols);
|
|
144
187
|
const duplicatesMs = Date.now() - checkpoint;
|
|
145
188
|
checkpoint = Date.now();
|
|
146
189
|
const moduleGraph = buildModuleGraph(
|
|
@@ -166,7 +209,7 @@ export async function buildAppGraph(
|
|
|
166
209
|
symbols: sortedSymbols,
|
|
167
210
|
edges: stableSortEdges(buildAppGraphEdges(sortedSymbols, moduleGraph)),
|
|
168
211
|
moduleGraph,
|
|
169
|
-
diagnostics: [...parseDiagnostics, ...dupDiagnostics],
|
|
212
|
+
diagnostics: [...parseDiagnostics, ...dupDiagnostics, ...unnamedExportDiagnostics],
|
|
170
213
|
};
|
|
171
214
|
recordAppGraphProfile(graph, {
|
|
172
215
|
normalizeMs,
|
|
@@ -4,7 +4,12 @@ export function renderClientTypesTs(): string {
|
|
|
4
4
|
return `export type ForgeStaticAuth = {
|
|
5
5
|
userId?: string;
|
|
6
6
|
tenantId?: string;
|
|
7
|
+
organizationId?: string;
|
|
8
|
+
organizationMembershipId?: string;
|
|
7
9
|
role?: string;
|
|
10
|
+
roles?: string[];
|
|
11
|
+
permissions?: string[];
|
|
12
|
+
claims?: Record<string, unknown>;
|
|
8
13
|
token?: string;
|
|
9
14
|
getToken?: () => string | Promise<string>;
|
|
10
15
|
headers?: Record<string, string>;
|
|
@@ -14,7 +19,12 @@ export function renderClientTypesTs(): string {
|
|
|
14
19
|
export type ForgeResolvedAuth = {
|
|
15
20
|
userId?: string;
|
|
16
21
|
tenantId?: string;
|
|
22
|
+
organizationId?: string;
|
|
23
|
+
organizationMembershipId?: string;
|
|
17
24
|
role?: string;
|
|
25
|
+
roles?: string[];
|
|
26
|
+
permissions?: string[];
|
|
27
|
+
claims?: Record<string, unknown>;
|
|
18
28
|
token?: string;
|
|
19
29
|
getToken?: () => string | Promise<string>;
|
|
20
30
|
headers?: Record<string, string>;
|
|
@@ -159,7 +169,12 @@ async function resolveAuthHeaders(
|
|
|
159
169
|
typeof value === "string" &&
|
|
160
170
|
key !== "userId" &&
|
|
161
171
|
key !== "tenantId" &&
|
|
172
|
+
key !== "organizationId" &&
|
|
173
|
+
key !== "organizationMembershipId" &&
|
|
162
174
|
key !== "role" &&
|
|
175
|
+
key !== "roles" &&
|
|
176
|
+
key !== "permissions" &&
|
|
177
|
+
key !== "claims" &&
|
|
163
178
|
key !== "token"
|
|
164
179
|
) {
|
|
165
180
|
headers[key] = value;
|
|
@@ -172,9 +187,25 @@ async function resolveAuthHeaders(
|
|
|
172
187
|
if (resolved.tenantId) {
|
|
173
188
|
headers["x-forge-tenant-id"] = resolved.tenantId;
|
|
174
189
|
}
|
|
190
|
+
if (resolved.organizationId) {
|
|
191
|
+
headers["x-forge-organization-id"] = resolved.organizationId;
|
|
192
|
+
headers["x-forge-tenant-id"] ??= resolved.organizationId;
|
|
193
|
+
}
|
|
194
|
+
if (resolved.organizationMembershipId) {
|
|
195
|
+
headers["x-forge-organization-membership-id"] = resolved.organizationMembershipId;
|
|
196
|
+
}
|
|
175
197
|
if (resolved.role) {
|
|
176
198
|
headers["x-forge-role"] = resolved.role;
|
|
177
199
|
}
|
|
200
|
+
if (Array.isArray(resolved.roles)) {
|
|
201
|
+
headers["x-forge-roles"] = JSON.stringify(resolved.roles);
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(resolved.permissions)) {
|
|
204
|
+
headers["x-forge-permissions"] = JSON.stringify(resolved.permissions);
|
|
205
|
+
}
|
|
206
|
+
if (resolved.claims && typeof resolved.claims === "object") {
|
|
207
|
+
headers["x-forge-claims"] = JSON.stringify(resolved.claims);
|
|
208
|
+
}
|
|
178
209
|
|
|
179
210
|
return headers;
|
|
180
211
|
}
|
|
@@ -36,6 +36,22 @@ function unwrapStringLiteral(node: SyntaxNode): string | null {
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function unwrapNullableType(node: SyntaxNode): string | null {
|
|
40
|
+
if (node.type !== "call_expression") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const functionNode = node.childForFieldName("function");
|
|
44
|
+
if (functionNode?.type !== "identifier" || functionNode.text !== "nullable") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const argsNode = node.childForFieldName("arguments");
|
|
48
|
+
const firstArg = argsNode?.namedChildren.find((child) =>
|
|
49
|
+
child.type !== "," && child.type !== "comment" && child.type !== "(" && child.type !== ")"
|
|
50
|
+
);
|
|
51
|
+
const inner = firstArg ? unwrapStringLiteral(firstArg) : null;
|
|
52
|
+
return inner ? `${inner}?` : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
function extractFieldsFromObject(objectNode: SyntaxNode, options: { skipConfigName?: boolean } = {}): DataField[] {
|
|
40
56
|
const fields: DataField[] = [];
|
|
41
57
|
|
|
@@ -64,7 +80,7 @@ function extractFieldsFromObject(objectNode: SyntaxNode, options: { skipConfigNa
|
|
|
64
80
|
continue;
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
const typeValue = unwrapStringLiteral(valueNode);
|
|
83
|
+
const typeValue = unwrapStringLiteral(valueNode) ?? unwrapNullableType(valueNode);
|
|
68
84
|
if (typeValue !== null) {
|
|
69
85
|
fields.push({ name: key, type: typeValue });
|
|
70
86
|
}
|
|
@@ -34,6 +34,7 @@ const SYSTEM_LIVE_SUBSCRIPTION_DEBUG_SQL = `CREATE TABLE IF NOT EXISTS _forge_li
|
|
|
34
34
|
|
|
35
35
|
interface ParsedFieldType {
|
|
36
36
|
sqlType: string;
|
|
37
|
+
nullable?: boolean;
|
|
37
38
|
references?: { table: string; column: string };
|
|
38
39
|
checkConstraint?: string;
|
|
39
40
|
}
|
|
@@ -102,28 +103,35 @@ function parseFieldType(
|
|
|
102
103
|
tableName: string,
|
|
103
104
|
diagnostics: Diagnostic[],
|
|
104
105
|
): ParsedFieldType | null {
|
|
105
|
-
const
|
|
106
|
+
const declared = field.type.trim();
|
|
107
|
+
const nullable = declared.endsWith("?");
|
|
108
|
+
const raw = (nullable ? declared.slice(0, -1) : declared).trim().toLowerCase();
|
|
109
|
+
|
|
110
|
+
const withNullability = (parsed: Omit<ParsedFieldType, "nullable">): ParsedFieldType => ({
|
|
111
|
+
...parsed,
|
|
112
|
+
...(nullable ? { nullable: true } : {}),
|
|
113
|
+
});
|
|
106
114
|
|
|
107
115
|
if (raw === "uuid") {
|
|
108
|
-
return { sqlType: "uuid" };
|
|
116
|
+
return withNullability({ sqlType: "uuid" });
|
|
109
117
|
}
|
|
110
118
|
if (raw === "text" || raw === "string") {
|
|
111
|
-
return { sqlType: "text" };
|
|
119
|
+
return withNullability({ sqlType: "text" });
|
|
112
120
|
}
|
|
113
121
|
if (raw === "number") {
|
|
114
|
-
return { sqlType: "double precision" };
|
|
122
|
+
return withNullability({ sqlType: "double precision" });
|
|
115
123
|
}
|
|
116
124
|
if (raw === "integer" || raw === "int") {
|
|
117
|
-
return { sqlType: "integer" };
|
|
125
|
+
return withNullability({ sqlType: "integer" });
|
|
118
126
|
}
|
|
119
127
|
if (raw === "boolean" || raw === "bool") {
|
|
120
|
-
return { sqlType: "boolean" };
|
|
128
|
+
return withNullability({ sqlType: "boolean" });
|
|
121
129
|
}
|
|
122
|
-
if (raw === "timestamp") {
|
|
123
|
-
return { sqlType: "timestamptz" };
|
|
130
|
+
if (raw === "timestamp" || raw === "timestamptz") {
|
|
131
|
+
return withNullability({ sqlType: "timestamptz" });
|
|
124
132
|
}
|
|
125
133
|
if (raw === "json") {
|
|
126
|
-
return { sqlType: "jsonb" };
|
|
134
|
+
return withNullability({ sqlType: "jsonb" });
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
if (raw.startsWith("enum:")) {
|
|
@@ -144,13 +152,14 @@ function parseFieldType(
|
|
|
144
152
|
}
|
|
145
153
|
const checkValues = values.map((value) => `'${value.replace(/'/g, "''")}'`).join(", ");
|
|
146
154
|
return {
|
|
155
|
+
...(nullable ? { nullable: true } : {}),
|
|
147
156
|
sqlType: "text",
|
|
148
157
|
checkConstraint: `${quoteIdent(toSnakeCase(field.name))} IN (${checkValues})`,
|
|
149
158
|
};
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
if (raw.startsWith("enum_")) {
|
|
153
|
-
return { sqlType: "text" };
|
|
162
|
+
return withNullability({ sqlType: "text" });
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
if (raw.startsWith("ref:")) {
|
|
@@ -166,6 +175,7 @@ function parseFieldType(
|
|
|
166
175
|
return null;
|
|
167
176
|
}
|
|
168
177
|
return {
|
|
178
|
+
...(nullable ? { nullable: true } : {}),
|
|
169
179
|
sqlType: "uuid",
|
|
170
180
|
references: { table: toSnakeCase(refTable), column: "id" },
|
|
171
181
|
};
|
|
@@ -173,6 +183,7 @@ function parseFieldType(
|
|
|
173
183
|
|
|
174
184
|
if (raw === "ref") {
|
|
175
185
|
return {
|
|
186
|
+
...(nullable ? { nullable: true } : {}),
|
|
176
187
|
sqlType: "uuid",
|
|
177
188
|
references: { table: toSnakeCase(field.name.replace(/Id$/i, "")), column: "id" },
|
|
178
189
|
};
|
|
@@ -190,7 +201,7 @@ function parseFieldType(
|
|
|
190
201
|
|
|
191
202
|
function defaultExprForField(field: DataField): string | undefined {
|
|
192
203
|
const name = field.name;
|
|
193
|
-
const type = field.type.trim().toLowerCase();
|
|
204
|
+
const type = field.type.trim().replace(/\?$/, "").toLowerCase();
|
|
194
205
|
|
|
195
206
|
if (name === "id" && type === "uuid") {
|
|
196
207
|
return "gen_random_uuid()";
|
|
@@ -238,7 +249,7 @@ function buildColumns(table: DataTable, diagnostics: Diagnostic[]): ColumnDef[]
|
|
|
238
249
|
name: snakeName,
|
|
239
250
|
fieldName: field.name,
|
|
240
251
|
sqlType: parsed.sqlType,
|
|
241
|
-
nullable: false,
|
|
252
|
+
nullable: isPrimaryKey ? false : parsed.nullable === true,
|
|
242
253
|
primaryKey: isPrimaryKey,
|
|
243
254
|
defaultExpr,
|
|
244
255
|
references: parsed.references,
|
|
@@ -250,7 +261,7 @@ function buildColumns(table: DataTable, diagnostics: Diagnostic[]): ColumnDef[]
|
|
|
250
261
|
const idField = fields.find((field) => field.name === "id");
|
|
251
262
|
diagnostics.push(
|
|
252
263
|
createDiagnostic({
|
|
253
|
-
severity: "
|
|
264
|
+
severity: "warning",
|
|
254
265
|
code: FORGE_DB_INVALID_SQL_PLAN,
|
|
255
266
|
message: `table '${table.name}' declares id as '${idField?.type ?? "unknown"}'; Forge runtime requires id: "uuid" or no id field so Forge can generate one`,
|
|
256
267
|
file: table.file,
|
|
@@ -27,6 +27,8 @@ export const FORGE_DUP_RUNTIME_ENTRY = "FORGE_DUP_RUNTIME_ENTRY" as const;
|
|
|
27
27
|
export const FORGE_RUNTIME_UNRESOLVABLE = "FORGE_RUNTIME_UNRESOLVABLE" as const;
|
|
28
28
|
export const FORGE_RUNTIME_NOT_FOUND = "FORGE_RUNTIME_NOT_FOUND" as const;
|
|
29
29
|
export const FORGE_RUNTIME_GUARD_BLOCKED = "FORGE_RUNTIME_GUARD_BLOCKED" as const;
|
|
30
|
+
export const FORGE_RUNTIME_EXPORT_NAME_REQUIRED =
|
|
31
|
+
"FORGE_RUNTIME_EXPORT_NAME_REQUIRED" as const;
|
|
30
32
|
export const FORGE_EXTERNAL_RUNTIME_NOT_FOUND =
|
|
31
33
|
"FORGE_EXTERNAL_RUNTIME_NOT_FOUND" as const;
|
|
32
34
|
export const FORGE_EXTERNAL_RUNTIME_UNSUPPORTED =
|
|
@@ -46,6 +48,8 @@ export const FORGE_DB_MIGRATION_FAILED = "FORGE_DB_MIGRATION_FAILED" as const;
|
|
|
46
48
|
export const FORGE_DB_UNSUPPORTED_FIELD_TYPE =
|
|
47
49
|
"FORGE_DB_UNSUPPORTED_FIELD_TYPE" as const;
|
|
48
50
|
export const FORGE_DB_INVALID_SQL_PLAN = "FORGE_DB_INVALID_SQL_PLAN" as const;
|
|
51
|
+
export const FORGE_DB_EMPTY_TIMESTAMP_LITERAL =
|
|
52
|
+
"FORGE_DB_EMPTY_TIMESTAMP_LITERAL" as const;
|
|
49
53
|
export const FORGE_DB_TRANSACTION_FAILED = "FORGE_DB_TRANSACTION_FAILED" as const;
|
|
50
54
|
export const FORGE_DB_OUTBOX_WRITE_FAILED = "FORGE_DB_OUTBOX_WRITE_FAILED" as const;
|
|
51
55
|
export const FORGE_DB_ADAPTER_UNAVAILABLE = "FORGE_DB_ADAPTER_UNAVAILABLE" as const;
|
|
@@ -414,6 +418,7 @@ export const DIAGNOSTIC_CODES = [
|
|
|
414
418
|
FORGE_RUNTIME_UNRESOLVABLE,
|
|
415
419
|
FORGE_RUNTIME_NOT_FOUND,
|
|
416
420
|
FORGE_RUNTIME_GUARD_BLOCKED,
|
|
421
|
+
FORGE_RUNTIME_EXPORT_NAME_REQUIRED,
|
|
417
422
|
FORGE_EXTERNAL_RUNTIME_NOT_FOUND,
|
|
418
423
|
FORGE_EXTERNAL_RUNTIME_UNSUPPORTED,
|
|
419
424
|
FORGE_EXTERNAL_RUNTIME_FAILED,
|
|
@@ -427,6 +432,7 @@ export const DIAGNOSTIC_CODES = [
|
|
|
427
432
|
FORGE_DB_MIGRATION_FAILED,
|
|
428
433
|
FORGE_DB_UNSUPPORTED_FIELD_TYPE,
|
|
429
434
|
FORGE_DB_INVALID_SQL_PLAN,
|
|
435
|
+
FORGE_DB_EMPTY_TIMESTAMP_LITERAL,
|
|
430
436
|
FORGE_DB_TRANSACTION_FAILED,
|
|
431
437
|
FORGE_DB_OUTBOX_WRITE_FAILED,
|
|
432
438
|
FORGE_DB_ADAPTER_UNAVAILABLE,
|
|
@@ -47,6 +47,13 @@ function defaultGuidanceForCode(code: string): DiagnosticGuidance | null {
|
|
|
47
47
|
docs: ["src/forge/_generated/runtimeRules.md", "AGENTS.md"],
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
if (code === "FORGE_RUNTIME_EXPORT_NAME_REQUIRED") {
|
|
51
|
+
return {
|
|
52
|
+
fixHint: "Forge runtime entries must be named exports. Replace `export default command(...)` with `export const createThing = command(...)` so generated API, frontend bindings, and capability maps have a stable name.",
|
|
53
|
+
suggestedCommands: ["forge check --json", "forge generate"],
|
|
54
|
+
docs: ["AGENTS.md", "src/forge/_generated/runtimeRules.md"],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
50
57
|
if (code === "FORGE_SECRET_DIRECT_PROCESS_ENV") {
|
|
51
58
|
return {
|
|
52
59
|
fixHint: "Use ctx.secrets or ctx.config for Forge runtime secrets/config; public frontend bridge env may use the framework's public env surface.",
|
|
@@ -75,6 +82,13 @@ function defaultGuidanceForCode(code: string): DiagnosticGuidance | null {
|
|
|
75
82
|
docs: ["src/forge/_generated/agentContract.json"],
|
|
76
83
|
};
|
|
77
84
|
}
|
|
85
|
+
if (code === "FORGE_DB_EMPTY_TIMESTAMP_LITERAL") {
|
|
86
|
+
return {
|
|
87
|
+
fixHint: "Do not use an empty string for timestamp fields. Use a real ISO timestamp, omit an optional timestamp field, declare it as `timestamp?` / `nullable(\"timestamp\")`, or model it as text if empty string is meaningful.",
|
|
88
|
+
suggestedCommands: ["forge check --json", "forge dev --db pglite --once --json"],
|
|
89
|
+
docs: ["AGENTS.md"],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
78
92
|
if (code.startsWith("FORGE_UI_")) {
|
|
79
93
|
return {
|
|
80
94
|
fixHint: "Inspect the last UI run and use the UI report as repair input.",
|