forgeos 0.1.0-alpha.39 → 0.1.0-alpha.40

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 CHANGED
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.39 input=1c15364ea76cedba476c98d2f661e485cdee778fa10e7b06f7db1b6f23b1dc4a content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
1
+ // @forge-generated generator=0.1.0-alpha.40 input=34767fc77e63b67b94ae661c501b2aa362dd8234ba16c12cf84a55da5e94de28 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
2
2
  # AGENTS.md
3
3
 
4
4
  <!-- forge-generated:start -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # forgeos
2
2
 
3
+ ## 0.1.0-alpha.40
4
+
5
+ ### Patch Changes
6
+
7
+ - Complete the WorkOS/AuthKit production-like app setup loop.
8
+
9
+ - Add `forge workos setup --real --file workos-seed.yml --json` as the
10
+ explicit no-dashboard apply path for WorkOS hosted redirect URI, CORS,
11
+ homepage, webhook, and seed configuration.
12
+ - Make `forge workos doctor` validate production OIDC/JWT readiness,
13
+ browser AuthKit environment variables, the frontend AuthKit package, and
14
+ actual AuthKit provider mounting in the web app shell.
15
+ - Generate a Vite React WorkOS bridge that wraps `AuthKitProvider`, forwards
16
+ `getAccessToken()` into `ForgeProvider`, preserves `forgeUrl`, and handles
17
+ the `/login` sign-in endpoint flow.
18
+ - Teach `forge add auth workos` to install `@workos-inc/authkit-react` in a
19
+ detected `web/` workspace and automatically rewrite the default Forge Vite
20
+ root from local `devAuth` to `ForgeWorkOSAuthProvider`.
21
+ - Keep custom web roots safe by leaving them unchanged and emitting actionable
22
+ doctor/UI-audit guidance when AuthKit still needs to be mounted manually.
23
+ - Expose production auth readiness in `/health` and make `authmd check` fail
24
+ when OIDC/JWT mode is enabled without issuer/JWKS configuration.
25
+ - Improve `forge dev` lifecycle diagnostics and port-busy recovery hints for
26
+ agent-run app previews.
27
+
3
28
  ## 0.1.0-alpha.39
4
29
 
5
30
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgeos",
3
- "version": "0.1.0-alpha.39",
3
+ "version": "0.1.0-alpha.40",
4
4
  "description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.39","releaseId":"forgeos@0.1.0-alpha.39+unknown","schemaVersion":"0.1.0"}
1
+ {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.40","releaseId":"forgeos@0.1.0-alpha.40+unknown","schemaVersion":"0.1.0"}
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.39 input=1c15364ea76cedba476c98d2f661e485cdee778fa10e7b06f7db1b6f23b1dc4a content=d76bcca47149c3378aca45191d0df75cbc6c25b49548ba94b7e2ac4ecbaa91a8
1
+ // @forge-generated generator=0.1.0-alpha.40 input=34767fc77e63b67b94ae661c501b2aa362dd8234ba16c12cf84a55da5e94de28 content=140291417df07bee6f2a7973ba1655fa53741dcb94b0447fc7e46d2a7a8f5d2b
2
2
  export const releaseManifest = {
3
3
  "defaultProvider": "local",
4
4
  "diagnostics": [],
@@ -19,7 +19,7 @@ export const releaseManifest = {
19
19
  "custom"
20
20
  ],
21
21
  "packageName": "forgeos",
22
- "packageVersion": "0.1.0-alpha.39",
23
- "releaseId": "forgeos@0.1.0-alpha.39+unknown",
22
+ "packageVersion": "0.1.0-alpha.40",
23
+ "releaseId": "forgeos@0.1.0-alpha.40+unknown",
24
24
  "schemaVersion": "0.1.0"
25
25
  } as const;
@@ -167,6 +167,59 @@ function metadataPathFor(markdownPath: string): string {
167
167
  : `${dirname(markdownPath)}/.well-known/oauth-protected-resource`;
168
168
  }
169
169
 
170
+ function parseEnvText(text: string): Record<string, string> {
171
+ const values: Record<string, string> = {};
172
+ for (const line of text.split(/\r?\n/)) {
173
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
174
+ if (!match) continue;
175
+ values[match[1]!] = (match[2] ?? "").trim().replace(/^["']|["']$/g, "");
176
+ }
177
+ return values;
178
+ }
179
+
180
+ function readRuntimeEnv(workspaceRoot: string): Record<string, string> {
181
+ const read = (path: string) => {
182
+ const absolute = join(workspaceRoot, path);
183
+ return existsSync(absolute) ? parseEnvText(readFileSync(absolute, "utf8")) : {};
184
+ };
185
+ return {
186
+ ...read(".env"),
187
+ ...read(".env.local"),
188
+ ...Object.fromEntries(
189
+ Object.entries(process.env)
190
+ .filter(([key]) => key.startsWith("FORGE_") || key.startsWith("WORKOS_") || key.startsWith("VITE_WORKOS_"))
191
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string"),
192
+ ),
193
+ };
194
+ }
195
+
196
+ function valuePresent(env: Record<string, string>, name: string): boolean {
197
+ return typeof env[name] === "string" && env[name]!.trim().length > 0;
198
+ }
199
+
200
+ function authEnvDiagnostics(workspaceRoot: string): AuthMdCommandResult["diagnostics"] {
201
+ const env = readRuntimeEnv(workspaceRoot);
202
+ const mode = env.FORGE_AUTH_MODE;
203
+ if (mode !== "oidc" && mode !== "jwt") {
204
+ return [];
205
+ }
206
+ const diagnostics: AuthMdCommandResult["diagnostics"] = [];
207
+ if (!valuePresent(env, "FORGE_AUTH_ISSUER")) {
208
+ diagnostics.push({
209
+ code: "FORGE_AUTHMD_AUTH_ENV_MISSING",
210
+ message: "FORGE_AUTH_MODE uses production auth, but FORGE_AUTH_ISSUER is missing; for hosted WorkOS use https://api.workos.com",
211
+ });
212
+ }
213
+ if (!valuePresent(env, "FORGE_AUTH_JWKS_URI")) {
214
+ const clientId = env.WORKOS_CLIENT_ID || env.VITE_WORKOS_CLIENT_ID || "<WORKOS_CLIENT_ID>";
215
+ diagnostics.push({
216
+ code: "FORGE_AUTHMD_AUTH_ENV_MISSING",
217
+ message: `FORGE_AUTH_MODE uses production auth, but FORGE_AUTH_JWKS_URI is missing; for hosted WorkOS use https://api.workos.com/sso/jwks/${clientId}`,
218
+ });
219
+ }
220
+ return diagnostics;
221
+ }
222
+
170
223
  function renderAuthMd(workspaceRoot: string): AuthMdCommandResult & { content: string; metadataContent: string } {
171
224
  const contract = readGeneratedJson<AgentContractLike>(
172
225
  workspaceRoot,
@@ -340,6 +393,15 @@ export function runAuthMdCommand(options: AuthMdCommandOptions): AuthMdCommandRe
340
393
  }
341
394
 
342
395
  if (options.subcommand === "check") {
396
+ const envDiagnostics = authEnvDiagnostics(options.workspaceRoot);
397
+ if (envDiagnostics.length > 0) {
398
+ return {
399
+ ...result,
400
+ ok: false,
401
+ diagnostics: envDiagnostics,
402
+ exitCode: 1,
403
+ };
404
+ }
343
405
  return {
344
406
  ...result,
345
407
  ok: !changed,
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { closeSync, openSync } from "node:fs";
2
+ import { closeSync, readFileSync, readlinkSync, openSync } from "node:fs";
3
3
  import { createServer as createNetServer } from "node:net";
4
4
  import { run } from "../compiler/orchestrator/run.ts";
5
5
  import { basename, join } from "node:path";
@@ -213,12 +213,30 @@ function removeDevPidFile(workspaceRoot: string): void {
213
213
  }
214
214
  }
215
215
 
216
+ function processDetails(pid: number): { pid: number; cwd?: string; command?: string } {
217
+ const details: { pid: number; cwd?: string; command?: string } = { pid };
218
+ try {
219
+ details.cwd = readlinkSync(`/proc/${pid}/cwd`);
220
+ } catch {
221
+ // /proc is Linux-only; process metadata is best-effort.
222
+ }
223
+ try {
224
+ const raw = readFileSync(`/proc/${pid}/cmdline`, "utf8");
225
+ const command = raw.split("\0").filter(Boolean).join(" ");
226
+ if (command) details.command = command;
227
+ } catch {
228
+ // /proc is Linux-only; process metadata is best-effort.
229
+ }
230
+ return details;
231
+ }
232
+
216
233
  function printDevLifecycleResult(input: {
217
234
  options: DevCommandOptions;
218
235
  ok: boolean;
219
236
  action: "detach" | "status" | "stop";
220
237
  running: boolean;
221
238
  pid?: number;
239
+ process?: { pid: number; cwd?: string; command?: string };
222
240
  logFile: string;
223
241
  message: string;
224
242
  exitCode: 0 | 1;
@@ -228,6 +246,7 @@ function printDevLifecycleResult(input: {
228
246
  action: input.action,
229
247
  running: input.running,
230
248
  ...(input.pid !== undefined ? { pid: input.pid } : {}),
249
+ ...(input.process ? { process: input.process } : {}),
231
250
  logFile: input.logFile,
232
251
  message: input.message,
233
252
  nextActions: input.running
@@ -240,6 +259,8 @@ function printDevLifecycleResult(input: {
240
259
  } else {
241
260
  process.stdout.write(`${input.message}\n`);
242
261
  if (input.pid !== undefined) process.stdout.write(`pid: ${input.pid}\n`);
262
+ if (input.process?.cwd) process.stdout.write(`cwd: ${input.process.cwd}\n`);
263
+ if (input.process?.command) process.stdout.write(`command: ${input.process.command}\n`);
243
264
  process.stdout.write(`log: ${input.logFile}\n`);
244
265
  }
245
266
  return { exitCode: input.exitCode };
@@ -278,6 +299,7 @@ function runDevStatus(options: DevCommandOptions): DevCommandResult {
278
299
  action: "status",
279
300
  running,
280
301
  ...(pid !== null ? { pid } : {}),
302
+ ...(pid !== null && running ? { process: processDetails(pid) } : {}),
281
303
  logFile: paths.logFile,
282
304
  message: running ? "forge dev detached server is running" : "forge dev detached server is not running",
283
305
  exitCode: 0,
@@ -295,6 +317,7 @@ function runDevStop(options: DevCommandOptions): DevCommandResult {
295
317
  action: "stop",
296
318
  running: false,
297
319
  ...(pid !== null ? { pid } : {}),
320
+ ...(pid !== null ? { process: processDetails(pid) } : {}),
298
321
  logFile: paths.logFile,
299
322
  message: "no detached forge dev server was running",
300
323
  exitCode: 0,
@@ -308,6 +331,7 @@ function runDevStop(options: DevCommandOptions): DevCommandResult {
308
331
  action: "stop",
309
332
  running: false,
310
333
  pid,
334
+ process: processDetails(pid),
311
335
  logFile: paths.logFile,
312
336
  message: "stopped detached forge dev server",
313
337
  exitCode: 0,
@@ -336,6 +360,7 @@ function runDevDetach(options: DevCommandOptions): DevCommandResult {
336
360
  action: "detach",
337
361
  running: true,
338
362
  pid: existingPid,
363
+ process: processDetails(existingPid),
339
364
  logFile: paths.logFile,
340
365
  message: "forge dev detached server is already running",
341
366
  exitCode: 0,
@@ -382,6 +407,7 @@ function runDevDetach(options: DevCommandOptions): DevCommandResult {
382
407
  action: "detach",
383
408
  running: true,
384
409
  pid: child.pid,
410
+ process: processDetails(child.pid),
385
411
  logFile: paths.logFile,
386
412
  message: "started detached forge dev server",
387
413
  exitCode: 0,
@@ -474,6 +500,8 @@ function classifyDevStartFailure(input: {
474
500
  /port\s+\d+\s+.*in use/i.test(input.rawMessage);
475
501
  if (busy) {
476
502
  const suggestedCommands = [
503
+ "forge dev status --json",
504
+ "forge dev stop --json",
477
505
  `forge dev --port 0${input.webPort ? ` --web-port ${input.webPort}` : ""} --json`,
478
506
  "forge doctor windows --json",
479
507
  ];
@@ -198,6 +198,7 @@ async function createFieldTestApp(options: FieldTestCommandOptions): Promise<Fie
198
198
  `${options.packageManager} run forge -- authmd check --json`,
199
199
  `${options.packageManager} run forge -- workos doctor --json`,
200
200
  `${options.packageManager} run forge -- workos seed --file workos-seed.yml --dry-run --json`,
201
+ `${options.packageManager} run forge -- workos setup --file workos-seed.yml --json`,
201
202
  ]
202
203
  : []),
203
204
  `${options.packageManager} run forge -- dev --once --json`,
@@ -37,6 +37,7 @@ function formatHelp(): string {
37
37
  " forge workos doctor --json Check WorkOS AuthKit/FGA files, claims, seed, webhook, and tenant guards",
38
38
  " forge workos doctor --yes --json Run local checks, then delegate to npx --yes workos@latest doctor",
39
39
  " forge workos seed --file workos-seed.yml --dry-run --json Validate WorkOS seed without hosted changes",
40
+ " forge workos setup --real --file workos-seed.yml --json Apply redirect/CORS/homepage config and seed WorkOS without dashboard",
40
41
  " forge deploy plan --target docker --json Explain production deploy gates and commands",
41
42
  " forge deploy check --production --json Gate auth, DB, metadata, generated artifacts, and liveQuery readiness",
42
43
  " forge deploy render docker Write Docker production deploy files under deploy/",
@@ -155,6 +155,7 @@ export type ForgeCommand =
155
155
  file?: string;
156
156
  yes: boolean;
157
157
  dryRun: boolean;
158
+ real?: boolean;
158
159
  workspaceRoot: string;
159
160
  }
160
161
  | {
@@ -575,7 +576,7 @@ const AUTH_SUBCOMMANDS: AuthSubcommand[] = [
575
576
  ];
576
577
  const BASELINE_SUBCOMMANDS: BaselineSubcommand[] = ["create", "status"];
577
578
  const AUTHMD_SUBCOMMANDS: AuthMdSubcommand[] = ["generate", "check"];
578
- const WORKOS_SUBCOMMANDS: WorkOSSubcommand[] = ["install", "doctor", "seed"];
579
+ const WORKOS_SUBCOMMANDS: WorkOSSubcommand[] = ["install", "doctor", "seed", "setup"];
579
580
  const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "check", "render", "verify"];
580
581
  const FIELD_TEST_SUBCOMMANDS: FieldTestSubcommand[] = ["create", "run", "report"];
581
582
  const SECURITY_SUBCOMMANDS: SecuritySubcommand[] = ["prove"];
@@ -1422,7 +1423,7 @@ export function parseCli(argv: string[]): ParsedCli {
1422
1423
  case "workos": {
1423
1424
  const subcommand = rest[0] as WorkOSSubcommand | undefined;
1424
1425
  if (!subcommand || !WORKOS_SUBCOMMANDS.includes(subcommand)) {
1425
- errors.push("forge workos requires subcommand: install, doctor, or seed");
1426
+ errors.push("forge workos requires subcommand: install, doctor, seed, or setup");
1426
1427
  return { command: null, workspaceRoot, errors };
1427
1428
  }
1428
1429
  return {
@@ -1433,6 +1434,7 @@ export function parseCli(argv: string[]): ParsedCli {
1433
1434
  file: parseOptionValue(argv, "--file"),
1434
1435
  yes: parseFlag(argv, "--yes"),
1435
1436
  dryRun: parseFlag(argv, "--dry-run"),
1437
+ real: parseFlag(argv, "--real"),
1436
1438
  workspaceRoot,
1437
1439
  },
1438
1440
  workspaceRoot,
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
6
6
  import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
7
7
 
8
- export type WorkOSSubcommand = "install" | "doctor" | "seed";
8
+ export type WorkOSSubcommand = "install" | "doctor" | "seed" | "setup";
9
9
 
10
10
  export interface WorkOSCommandOptions {
11
11
  subcommand: WorkOSSubcommand;
@@ -14,6 +14,7 @@ export interface WorkOSCommandOptions {
14
14
  file?: string;
15
15
  yes: boolean;
16
16
  dryRun: boolean;
17
+ real?: boolean;
17
18
  commandRunner?: WorkOSCommandRunner;
18
19
  }
19
20
 
@@ -25,7 +26,7 @@ export interface WorkOSCheck {
25
26
 
26
27
  export interface WorkOSCommandResult {
27
28
  ok: boolean;
28
- kind: "workos-install" | "workos-doctor" | "workos-seed";
29
+ kind: "workos-install" | "workos-doctor" | "workos-seed" | "workos-setup";
29
30
  checks: WorkOSCheck[];
30
31
  command?: string[];
31
32
  applied?: boolean;
@@ -114,6 +115,10 @@ export interface WorkOSSeedSummary {
114
115
  resourceTypes: string[];
115
116
  organizations: string[];
116
117
  domains: string[];
118
+ redirectUris: string[];
119
+ corsOrigins: string[];
120
+ homepageUrl?: string;
121
+ webhookEndpoints: Array<{ url: string; events: string[] }>;
117
122
  diagnostics: string[];
118
123
  }
119
124
 
@@ -139,8 +144,14 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
139
144
  const resourceTypes = new Set<string>();
140
145
  const organizations = new Set<string>();
141
146
  const domains = new Set<string>();
147
+ const redirectUris = new Set<string>();
148
+ const corsOrigins = new Set<string>();
149
+ let homepageUrl: string | undefined;
150
+ const webhookEndpoints: Array<{ url: string; events: string[] }> = [];
142
151
  const diagnostics: string[] = [];
143
152
  let section = "";
153
+ let configKey = "";
154
+ let currentWebhook: { url: string; events: string[] } | undefined;
144
155
 
145
156
  if (!raw.trim()) {
146
157
  return {
@@ -152,6 +163,9 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
152
163
  resourceTypes: [],
153
164
  organizations: [],
154
165
  domains: [],
166
+ redirectUris: [],
167
+ corsOrigins: [],
168
+ webhookEndpoints: [],
155
169
  diagnostics: [`${seedPath} is missing or empty`],
156
170
  };
157
171
  }
@@ -160,9 +174,47 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
160
174
  const rootSection = /^([a-zA-Z_][\w-]*):\s*$/.exec(line);
161
175
  if (rootSection) {
162
176
  section = rootSection[1]!;
177
+ configKey = "";
178
+ currentWebhook = undefined;
163
179
  continue;
164
180
  }
165
181
 
182
+ if (section === "config") {
183
+ const configEntry = /^\s{2}([a-zA-Z_][\w-]*):\s*(.*)$/.exec(line);
184
+ if (configEntry) {
185
+ configKey = configEntry[1]!;
186
+ const rest = configEntry[2] ?? "";
187
+ if (configKey === "redirect_uris") {
188
+ for (const value of quotedValues(rest)) redirectUris.add(value);
189
+ } else if (configKey === "cors_origins") {
190
+ for (const value of quotedValues(rest)) corsOrigins.add(value);
191
+ } else if (configKey === "homepage_url") {
192
+ homepageUrl = quotedValues(rest)[0] ?? (rest.trim().replace(/^["']|["']$/g, "") || homepageUrl);
193
+ } else if (configKey === "webhook_endpoints") {
194
+ currentWebhook = undefined;
195
+ }
196
+ }
197
+ const bulletValue = /^\s*-\s*["']?([^"'\]\s]+)["']?\s*$/.exec(line)?.[1];
198
+ if (bulletValue && configKey === "redirect_uris") {
199
+ redirectUris.add(bulletValue);
200
+ } else if (bulletValue && configKey === "cors_origins") {
201
+ corsOrigins.add(bulletValue);
202
+ } else if (bulletValue && configKey === "webhook_events" && currentWebhook) {
203
+ currentWebhook.events.push(bulletValue);
204
+ }
205
+ const webhookUrl = /^\s*-\s*url:\s*["']?([^"']+)["']?\s*$/.exec(line)?.[1] ??
206
+ /^\s{4}url:\s*["']?([^"']+)["']?\s*$/.exec(line)?.[1];
207
+ if (webhookUrl) {
208
+ currentWebhook = { url: webhookUrl.trim(), events: [] };
209
+ webhookEndpoints.push(currentWebhook);
210
+ configKey = "webhook_endpoints";
211
+ }
212
+ if (/^\s{4}events:\s*/.test(line) && currentWebhook) {
213
+ configKey = "webhook_events";
214
+ for (const value of quotedValues(line)) currentWebhook.events.push(value);
215
+ }
216
+ }
217
+
166
218
  const slug = parseSlug(line);
167
219
  if (slug) {
168
220
  if (section === "permissions") {
@@ -205,10 +257,48 @@ export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEE
205
257
  resourceTypes: uniqueSorted(resourceTypes),
206
258
  organizations: uniqueSorted(organizations),
207
259
  domains: uniqueSorted(domains),
260
+ redirectUris: uniqueSorted(redirectUris),
261
+ corsOrigins: uniqueSorted(corsOrigins),
262
+ ...(homepageUrl ? { homepageUrl } : {}),
263
+ webhookEndpoints: webhookEndpoints.map((endpoint) => ({
264
+ url: endpoint.url,
265
+ events: uniqueSorted(endpoint.events),
266
+ })),
208
267
  diagnostics,
209
268
  };
210
269
  }
211
270
 
271
+ function parseEnvText(text: string): Record<string, string> {
272
+ const values: Record<string, string> = {};
273
+ for (const line of text.split(/\r?\n/)) {
274
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
275
+ if (!match) continue;
276
+ const raw = match[2] ?? "";
277
+ values[match[1]!] = raw.trim().replace(/^["']|["']$/g, "");
278
+ }
279
+ return values;
280
+ }
281
+
282
+ function readRealEnv(workspaceRoot: string): Record<string, string> {
283
+ return {
284
+ ...parseEnvText(readRawText(workspaceRoot, ".env")),
285
+ ...parseEnvText(readRawText(workspaceRoot, ".env.local")),
286
+ ...Object.fromEntries(
287
+ Object.entries(process.env)
288
+ .filter(([key]) => key.startsWith("FORGE_") || key.startsWith("WORKOS_") || key.startsWith("VITE_WORKOS_"))
289
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string"),
290
+ ),
291
+ };
292
+ }
293
+
294
+ function hasValue(env: Record<string, string>, name: string): boolean {
295
+ return typeof env[name] === "string" && env[name]!.trim().length > 0;
296
+ }
297
+
298
+ function workosJwksUri(clientId: string | undefined): string {
299
+ return clientId ? `https://api.workos.com/sso/jwks/${clientId}` : "https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>";
300
+ }
301
+
212
302
  export function collectPolicyPermissions(workspaceRoot: string): string[] {
213
303
  const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
214
304
  policies?: Array<{ permissions?: string[] }>;
@@ -287,11 +377,26 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
287
377
  ...(packageJson?.dependencies ?? {}),
288
378
  ...(packageJson?.devDependencies ?? {}),
289
379
  };
380
+ const webPackageJson = readJson(workspaceRoot, "web/package.json") as {
381
+ dependencies?: Record<string, string>;
382
+ devDependencies?: Record<string, string>;
383
+ } | null;
384
+ const webDeps = {
385
+ ...(webPackageJson?.dependencies ?? {}),
386
+ ...(webPackageJson?.devDependencies ?? {}),
387
+ };
388
+ const hasWeb = webPackageJson !== null;
290
389
  const seed = parseSeedFile(workspaceRoot);
291
390
  const activePermissions = collectPolicyPermissions(workspaceRoot);
292
391
  const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
293
392
  const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
294
393
  const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
394
+ const realEnv = readRealEnv(workspaceRoot);
395
+ const authMode = realEnv.FORGE_AUTH_MODE;
396
+ const productionAuthEnabled = authMode === "oidc" || authMode === "jwt";
397
+ const issuerConfigured = hasValue(realEnv, "FORGE_AUTH_ISSUER");
398
+ const jwksConfigured = hasValue(realEnv, "FORGE_AUTH_JWKS_URI");
399
+ const clientId = realEnv.WORKOS_CLIENT_ID || realEnv.VITE_WORKOS_CLIENT_ID;
295
400
  const authRoutes = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/auth-routes.ts`);
296
401
  const fga = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/fga.ts`);
297
402
  const resourceMap = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/resource-map.ts`);
@@ -299,6 +404,11 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
299
404
  const policies = readText(workspaceRoot, "src/policies.workos.ts");
300
405
  const session = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/session.ts`);
301
406
  const webhook = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/webhook.ts`);
407
+ const generatedFrontendAuthBridge = readText(workspaceRoot, "web/src/lib/workos-auth.tsx");
408
+ const frontendAppShell = [
409
+ readText(workspaceRoot, "web/src/main.tsx"),
410
+ readText(workspaceRoot, "web/src/App.tsx"),
411
+ ].join("\n");
302
412
 
303
413
  return [
304
414
  {
@@ -318,8 +428,53 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
318
428
  },
319
429
  {
320
430
  name: "env-example",
321
- ok: exists(workspaceRoot, ".env.example"),
322
- detail: ".env.example exists",
431
+ ok: exists(workspaceRoot, ".env.example") &&
432
+ includesAll(readRawText(workspaceRoot, ".env.example"), [
433
+ "FORGE_AUTH_MODE=oidc",
434
+ "FORGE_AUTH_ISSUER=https://api.workos.com",
435
+ "FORGE_AUTH_JWKS_URI=",
436
+ "VITE_WORKOS_CLIENT_ID=",
437
+ "VITE_WORKOS_REDIRECT_URI=",
438
+ ]),
439
+ detail: ".env.example exists with Forge OIDC and browser AuthKit variables",
440
+ },
441
+ {
442
+ name: "production-auth-readiness",
443
+ ok: !productionAuthEnabled || (issuerConfigured && jwksConfigured),
444
+ detail: !productionAuthEnabled
445
+ ? "production OIDC/JWT env not enabled in .env/.env.local"
446
+ : issuerConfigured && jwksConfigured
447
+ ? "FORGE_AUTH_MODE uses production auth and issuer/JWKS are configured"
448
+ : `FORGE_AUTH_MODE=${authMode} requires FORGE_AUTH_ISSUER=https://api.workos.com and FORGE_AUTH_JWKS_URI=${workosJwksUri(clientId)}`,
449
+ },
450
+ {
451
+ name: "browser-authkit-env",
452
+ ok: !hasWeb || (hasValue(realEnv, "VITE_WORKOS_CLIENT_ID") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_CLIENT_ID=")) &&
453
+ (hasValue(realEnv, "VITE_WORKOS_REDIRECT_URI") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_REDIRECT_URI=")),
454
+ detail: hasWeb
455
+ ? "web workspace has VITE_WORKOS_CLIENT_ID and VITE_WORKOS_REDIRECT_URI guidance for AuthKit React"
456
+ : "no web workspace detected",
457
+ },
458
+ {
459
+ name: "browser-authkit-package",
460
+ ok: !hasWeb || "@workos-inc/authkit-react" in webDeps,
461
+ detail: hasWeb
462
+ ? "@workos-inc/authkit-react is present in web/package.json"
463
+ : "no web workspace detected",
464
+ },
465
+ {
466
+ name: "browser-authkit-bridge",
467
+ ok: !hasWeb || includesAll(generatedFrontendAuthBridge, ["AuthKitProvider", "getAccessToken", "ForgeProvider"]),
468
+ detail: hasWeb
469
+ ? "generated web/src/lib/workos-auth.tsx bridge provides AuthKitProvider and ForgeProvider token wiring"
470
+ : "no web workspace detected",
471
+ },
472
+ {
473
+ name: "browser-authkit-provider",
474
+ ok: !hasWeb || includesAll(frontendAppShell, ["AuthKitProvider", "getAccessToken", "ForgeProvider"]),
475
+ detail: hasWeb
476
+ ? "web app shell mounts AuthKitProvider and forwards getAccessToken to ForgeProvider"
477
+ : "no web workspace detected",
323
478
  },
324
479
  {
325
480
  name: "seed-file",
@@ -351,6 +506,13 @@ function collectWorkOSChecks(workspaceRoot: string): WorkOSCheck[] {
351
506
  ? `seed resource_types cover app graph: ${expectedResourceTypes.length > 0 ? expectedResourceTypes.join(", ") : seed.resourceTypes.join(", ")}`
352
507
  : `seed missing resource_type(s) for app graph: ${missingSeedResources.join(", ")}`,
353
508
  },
509
+ {
510
+ name: "seed-auth-config",
511
+ ok: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl),
512
+ detail: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl)
513
+ ? `seed config contains ${seed.redirectUris.length} redirect URI(s), ${seed.corsOrigins.length} CORS origin(s), and homepage URL`
514
+ : "seed config should include redirect_uris, cors_origins, and homepage_url for no-dashboard setup",
515
+ },
354
516
  {
355
517
  name: "authkit-routes",
356
518
  ok: includesAll(authRoutes, ["handleWorkOSAuthRequest", "/login", "/callback", "/logout", "/session"]),
@@ -432,6 +594,7 @@ function seedData(input: {
432
594
  dryRun?: boolean;
433
595
  seedFileSanitized?: boolean;
434
596
  seedAlreadyApplied?: boolean;
597
+ configActions?: WorkOSConfigActionResult[];
435
598
  nextCommand?: string;
436
599
  }): Record<string, unknown> {
437
600
  return {
@@ -442,10 +605,95 @@ function seedData(input: {
442
605
  dryRun: input.dryRun ?? false,
443
606
  seedFileSanitized: input.seedFileSanitized ?? false,
444
607
  seedAlreadyApplied: input.seedAlreadyApplied ?? false,
608
+ configActions: input.configActions ?? [],
445
609
  ...(input.nextCommand ? { nextCommand: input.nextCommand } : {}),
446
610
  };
447
611
  }
448
612
 
613
+ export interface WorkOSConfigActionResult {
614
+ name: string;
615
+ command?: string[];
616
+ ok: boolean;
617
+ skipped: boolean;
618
+ reason?: string;
619
+ stdout?: string;
620
+ stderr?: string;
621
+ status?: number | null;
622
+ }
623
+
624
+ function configActionsForSeed(seed: WorkOSSeedSummary): Array<{ name: string; command: string[]; skipReason?: string }> {
625
+ const actions: Array<{ name: string; command: string[]; skipReason?: string }> = [];
626
+ for (const uri of seed.redirectUris) {
627
+ actions.push({
628
+ name: "redirect-uri",
629
+ command: ["npx", "--yes", "workos@latest", "config", "redirect", "add", uri],
630
+ });
631
+ }
632
+ for (const origin of seed.corsOrigins) {
633
+ actions.push({
634
+ name: "cors-origin",
635
+ command: ["npx", "--yes", "workos@latest", "config", "cors", "add", origin],
636
+ });
637
+ }
638
+ if (seed.homepageUrl) {
639
+ actions.push({
640
+ name: "homepage-url",
641
+ command: ["npx", "--yes", "workos@latest", "config", "homepage-url", "set", seed.homepageUrl],
642
+ });
643
+ }
644
+ for (const endpoint of seed.webhookEndpoints) {
645
+ const events = endpoint.events.length > 0 ? endpoint.events.join(",") : "user.created,organization_membership.updated";
646
+ actions.push({
647
+ name: "webhook",
648
+ command: ["npx", "--yes", "workos@latest", "webhook", "create", "--url", endpoint.url, "--events", events],
649
+ skipReason: endpoint.url.startsWith("https://")
650
+ ? undefined
651
+ : "WorkOS hosted webhook endpoints require HTTPS; use a tunnel or production URL for this endpoint.",
652
+ });
653
+ }
654
+ return actions;
655
+ }
656
+
657
+ function runWorkOSConfigActions(
658
+ seed: WorkOSSeedSummary,
659
+ options: WorkOSCommandOptions,
660
+ dryRun: boolean,
661
+ ): WorkOSConfigActionResult[] {
662
+ return configActionsForSeed(seed).map((action) => {
663
+ if (action.skipReason) {
664
+ return {
665
+ name: action.name,
666
+ command: action.command,
667
+ ok: true,
668
+ skipped: true,
669
+ reason: action.skipReason,
670
+ };
671
+ }
672
+ if (dryRun) {
673
+ return {
674
+ name: action.name,
675
+ command: action.command,
676
+ ok: true,
677
+ skipped: true,
678
+ reason: "dry-run",
679
+ };
680
+ }
681
+ const child = runExternalCommand(action.command, options);
682
+ return {
683
+ name: action.name,
684
+ command: action.command,
685
+ ok: child.status === 0 || isWorkOSSeedAlreadyApplied(child.stdout, child.stderr),
686
+ skipped: false,
687
+ stdout: child.stdout,
688
+ stderr: child.stderr,
689
+ status: child.status,
690
+ ...(child.status !== 0 && isWorkOSSeedAlreadyApplied(child.stdout, child.stderr)
691
+ ? { reason: "already-applied" }
692
+ : {}),
693
+ };
694
+ });
695
+ }
696
+
449
697
  export function runWorkOSDoctorCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
450
698
  const checks = collectWorkOSChecks(options.workspaceRoot);
451
699
  const ok = checks.every((check) => check.ok);
@@ -553,6 +801,13 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
553
801
  ? `seed covers app resource type(s): ${expectedResourceTypes.join(", ") || "none required"}`
554
802
  : `seed missing resource type(s): ${missingSeedResources.join(", ")}`,
555
803
  },
804
+ {
805
+ name: "seed-hosted-config",
806
+ ok: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl),
807
+ detail: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl)
808
+ ? `seed includes hosted config: ${seed.redirectUris.length} redirect URI(s), ${seed.corsOrigins.length} CORS origin(s), homepage ${seed.homepageUrl}`
809
+ : "seed should include config.redirect_uris, config.cors_origins, and config.homepage_url",
810
+ },
556
811
  ];
557
812
  const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
558
813
  if (!checks.every((check) => check.ok)) {
@@ -567,6 +822,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
567
822
  };
568
823
  }
569
824
  if (options.dryRun) {
825
+ const configActions = runWorkOSConfigActions(seed, options, true);
570
826
  return {
571
827
  ok: true,
572
828
  kind: "workos-seed",
@@ -579,6 +835,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
579
835
  expectedResourceTypes,
580
836
  unusedSeedPermissions,
581
837
  dryRun: true,
838
+ configActions,
582
839
  nextCommand: `forge workos seed --file ${file} --json`,
583
840
  }),
584
841
  exitCode: 0,
@@ -594,6 +851,26 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
594
851
  preparedSeed.file,
595
852
  ];
596
853
  try {
854
+ const configActions = runWorkOSConfigActions(seed, options, false);
855
+ const configOk = configActions.every((action) => action.ok);
856
+ if (!configOk) {
857
+ return {
858
+ ok: false,
859
+ kind: "workos-seed",
860
+ checks,
861
+ command: configActions.find((action) => !action.ok)?.command ?? delegatedCommand,
862
+ applied: false,
863
+ data: seedData({
864
+ seed,
865
+ activePermissions,
866
+ expectedResourceTypes,
867
+ unusedSeedPermissions,
868
+ seedFileSanitized: preparedSeed.sanitized,
869
+ configActions,
870
+ }),
871
+ exitCode: 1,
872
+ };
873
+ }
597
874
  const child = runExternalCommand(delegatedCommand, options);
598
875
  const seedAlreadyApplied =
599
876
  child.status !== 0 && isWorkOSSeedAlreadyApplied(child.stdout, child.stderr);
@@ -610,6 +887,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
610
887
  unusedSeedPermissions,
611
888
  seedFileSanitized: preparedSeed.sanitized,
612
889
  seedAlreadyApplied,
890
+ configActions,
613
891
  }),
614
892
  stdout: child.stdout,
615
893
  stderr: child.stderr,
@@ -620,10 +898,79 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
620
898
  }
621
899
  }
622
900
 
901
+ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
902
+ const file = options.file ?? DEFAULT_SEED_FILE;
903
+ const checks = collectWorkOSChecks(options.workspaceRoot);
904
+ const localOk = checks.every((check) => check.ok);
905
+ const seed = parseSeedFile(options.workspaceRoot, file);
906
+ const setupDryRun = options.dryRun || !options.real;
907
+ const configActions = runWorkOSConfigActions(seed, options, true);
908
+ const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
909
+ if (!localOk) {
910
+ return {
911
+ ok: false,
912
+ kind: "workos-setup",
913
+ checks,
914
+ command,
915
+ applied: false,
916
+ data: {
917
+ dryRun: setupDryRun,
918
+ real: options.real ?? false,
919
+ seed,
920
+ configActions,
921
+ nextCommand: "forge workos doctor --json",
922
+ },
923
+ exitCode: 1,
924
+ };
925
+ }
926
+ if (setupDryRun) {
927
+ return {
928
+ ok: true,
929
+ kind: "workos-setup",
930
+ checks,
931
+ command,
932
+ applied: false,
933
+ data: {
934
+ dryRun: true,
935
+ real: false,
936
+ seed,
937
+ configActions,
938
+ nextCommand: `forge workos setup --real --file ${file} --json`,
939
+ },
940
+ exitCode: 0,
941
+ };
942
+ }
943
+ const seedResult = runWorkOSSeedCommand({
944
+ ...options,
945
+ subcommand: "seed",
946
+ dryRun: false,
947
+ file,
948
+ });
949
+ return {
950
+ ok: seedResult.ok,
951
+ kind: "workos-setup",
952
+ checks: seedResult.checks,
953
+ command: seedResult.command,
954
+ applied: seedResult.ok,
955
+ data: {
956
+ real: true,
957
+ seed,
958
+ seedResult: seedResult.data,
959
+ nextCommand: "forge workos doctor --json",
960
+ },
961
+ stdout: seedResult.stdout,
962
+ stderr: seedResult.stderr,
963
+ exitCode: seedResult.exitCode,
964
+ };
965
+ }
966
+
623
967
  export function runWorkOSCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
624
968
  if (options.subcommand === "install") {
625
969
  return runWorkOSInstallCommand(options);
626
970
  }
971
+ if (options.subcommand === "setup") {
972
+ return runWorkOSSetupCommand(options);
973
+ }
627
974
  return options.subcommand === "doctor"
628
975
  ? runWorkOSDoctorCommand(options)
629
976
  : runWorkOSSeedCommand(options);
@@ -659,5 +1006,15 @@ export function formatWorkOSHuman(result: WorkOSCommandResult): string {
659
1006
  lines.push("seed not applied; inspect stdout/stderr from the WorkOS CLI command");
660
1007
  }
661
1008
  }
1009
+ if (result.kind === "workos-setup" && !result.applied) {
1010
+ const data = result.data && typeof result.data === "object"
1011
+ ? result.data as { dryRun?: boolean; nextCommand?: string }
1012
+ : {};
1013
+ if (data.dryRun) {
1014
+ lines.push(`setup dry-run only; run ${data.nextCommand ?? "forge workos setup --real --file workos-seed.yml --json"} to apply hosted config`);
1015
+ } else {
1016
+ lines.push("setup not applied; inspect WorkOS checks and seed/config action output");
1017
+ }
1018
+ }
662
1019
  return `${lines.join("\n")}\n`;
663
1020
  }
@@ -354,8 +354,9 @@ function fileHashOrNull(path: string): string | null {
354
354
  function snapshotPackageManagerFiles(
355
355
  workspaceRoot: string,
356
356
  pm: PackageManagerAdapter,
357
+ extraPaths: string[] = [],
357
358
  ): Map<string, string | null> {
358
- const paths = ["package.json", pm.lockfile];
359
+ const paths = [...new Set(["package.json", pm.lockfile, ...extraPaths])];
359
360
  return new Map(
360
361
  paths.map((path) => [
361
362
  path,
@@ -377,6 +378,70 @@ function changedPackageManagerFiles(
377
378
  return changed.sort();
378
379
  }
379
380
 
381
+ function workosFrontendWorkspace(workspaceRoot: string): string | undefined {
382
+ return findFrontendWorkspace(workspaceRoot);
383
+ }
384
+
385
+ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string): {
386
+ changed: string[];
387
+ warnings: Diagnostic[];
388
+ } {
389
+ const mainRel = `${frontendWorkspace}/src/main.tsx`;
390
+ const mainPath = join(workspaceRoot, mainRel);
391
+ const current = nodeFileSystem.readText(mainPath);
392
+ if (current === null) {
393
+ return {
394
+ changed: [],
395
+ warnings: [
396
+ createDiagnostic({
397
+ severity: "warning",
398
+ code: "FORGE_WORKOS_AUTHKIT_ROOT_NOT_FOUND",
399
+ message: `generated WorkOS AuthKit bridge, but ${mainRel} was not found`,
400
+ fixHint: "Wrap the web app root with ForgeWorkOSAuthProvider from ./lib/workos-auth.",
401
+ suggestedCommands: ["forge workos doctor --json", "forge inspect ui --json"],
402
+ }),
403
+ ],
404
+ };
405
+ }
406
+ if (current.includes("ForgeWorkOSAuthProvider")) {
407
+ return { changed: [], warnings: [] };
408
+ }
409
+
410
+ const canRewriteDefaultViteRoot =
411
+ current.includes('import { ForgeProvider, forgeUrl } from "./lib/forge";') &&
412
+ current.includes("<ForgeProvider url={forgeUrl} devAuth>") &&
413
+ current.includes("</ForgeProvider>");
414
+
415
+ if (!canRewriteDefaultViteRoot) {
416
+ return {
417
+ changed: [],
418
+ warnings: [
419
+ createDiagnostic({
420
+ severity: "warning",
421
+ code: "FORGE_WORKOS_AUTHKIT_ROOT_CUSTOM",
422
+ message: `generated WorkOS AuthKit bridge, but ${mainRel} is custom and was not rewritten automatically`,
423
+ fixHint: "Import ForgeWorkOSAuthProvider from ./lib/workos-auth and wrap the app root with it so AuthKit tokens are passed to ForgeProvider.",
424
+ suggestedCommands: ["forge workos doctor --json", "forge inspect ui --json"],
425
+ }),
426
+ ],
427
+ };
428
+ }
429
+
430
+ const next = current
431
+ .replace(
432
+ 'import { ForgeProvider, forgeUrl } from "./lib/forge";',
433
+ 'import { ForgeWorkOSAuthProvider } from "./lib/workos-auth";',
434
+ )
435
+ .replace("<ForgeProvider url={forgeUrl} devAuth>", "<ForgeWorkOSAuthProvider>")
436
+ .replace("</ForgeProvider>", "</ForgeWorkOSAuthProvider>");
437
+
438
+ if (next !== current) {
439
+ nodeFileSystem.writeText(mainPath, next);
440
+ return { changed: [mainRel], warnings: [] };
441
+ }
442
+ return { changed: [], warnings: [] };
443
+ }
444
+
380
445
  async function analyzeRecipePackages(
381
446
  recipe: NonNullable<ReturnType<typeof resolveRecipe>>,
382
447
  ctx: ReturnType<typeof discover>,
@@ -668,13 +733,24 @@ export async function forgeAdd(
668
733
  const snapshot = snapshotVersionControlled(options.workspaceRoot);
669
734
 
670
735
  try {
671
- const packageManagerBefore = snapshotPackageManagerFiles(options.workspaceRoot, pm);
736
+ const frontendWorkspace = normalized === "workos" ? workosFrontendWorkspace(options.workspaceRoot) : undefined;
737
+ const packageManagerBefore = snapshotPackageManagerFiles(
738
+ options.workspaceRoot,
739
+ pm,
740
+ frontendWorkspace ? [packageJsonRelativeFor(frontendWorkspace)] : [],
741
+ );
672
742
  for (const pkg of recipe.packages) {
673
743
  await pm.add(pkg.packageName, {
674
744
  cwd: options.workspaceRoot,
675
745
  ignoreScripts: !options.allowScripts,
676
746
  });
677
747
  }
748
+ if (frontendWorkspace) {
749
+ await pm.add("@workos-inc/authkit-react", {
750
+ cwd: join(options.workspaceRoot, frontendWorkspace),
751
+ ignoreScripts: !options.allowScripts,
752
+ });
753
+ }
678
754
 
679
755
  const ctx = discover({ workspaceRoot: options.workspaceRoot });
680
756
  const { emitPlan, warnings, errors: analyzeErrors } = await buildAddPlan(
@@ -705,7 +781,11 @@ export async function forgeAdd(
705
781
  mode: "write",
706
782
  });
707
783
 
708
- const warningsCombined = [...warnings, ...emitResult.warnings];
784
+ const workosWeb = normalized === "workos" && frontendWorkspace
785
+ ? connectWorkOSReactRoot(options.workspaceRoot, frontendWorkspace)
786
+ : { changed: [] as string[], warnings: [] as Diagnostic[] };
787
+
788
+ const warningsCombined = [...warnings, ...emitResult.warnings, ...workosWeb.warnings];
709
789
  const errors = [...analyzeErrors, ...emitResult.errors];
710
790
 
711
791
  if (errors.length > 0) {
@@ -772,6 +852,7 @@ export async function forgeAdd(
772
852
  changed: [
773
853
  ...changedPackageManagerFiles(options.workspaceRoot, packageManagerBefore),
774
854
  ...emitResult.changed,
855
+ ...workosWeb.changed,
775
856
  ],
776
857
  unchanged: emitResult.unchanged,
777
858
  warnings: warningsCombined,
@@ -97,6 +97,7 @@ function shouldEmitAdapter(
97
97
  function buildGeneratedPaths(
98
98
  recipe: IntegrationRecipe,
99
99
  compatible: RuntimeContext[],
100
+ workspaceRoot: string,
100
101
  ): string[] {
101
102
  const paths: string[] = [];
102
103
 
@@ -120,6 +121,9 @@ function buildGeneratedPaths(
120
121
  }
121
122
 
122
123
  for (const rootFile of recipe.rootFiles ?? []) {
124
+ if (rootFile.startsWith("web/") && !nodeFileSystem.exists(join(workspaceRoot, "web", "package.json"))) {
125
+ continue;
126
+ }
123
127
  paths.push(rootFile);
124
128
  }
125
129
 
@@ -243,6 +247,9 @@ export function buildIntegrationEmitPlan(input: IntegrationPlanInput): EmitPlan
243
247
  }
244
248
 
245
249
  for (const rootFile of recipe.rootFiles ?? []) {
250
+ if (rootFile.startsWith("web/") && !nodeFileSystem.exists(join(ctx.workspaceRoot, "web", "package.json"))) {
251
+ continue;
252
+ }
246
253
  const content = renderRootFile(rootFile, templateCtx);
247
254
  files.push({
248
255
  path: rootFile,
@@ -282,7 +289,7 @@ export function buildIntegrationEmitPlan(input: IntegrationPlanInput): EmitPlan
282
289
  const matrix = buildRuntimeMatrix(allClassified);
283
290
  files.push(...buildGuardArtifactEmitFiles(matrix, appGraph.moduleGraph));
284
291
 
285
- const generatedFiles = buildGeneratedPaths(recipe, compatible);
292
+ const generatedFiles = buildGeneratedPaths(recipe, compatible, ctx.workspaceRoot);
286
293
  const lockEntry = buildLockEntry(recipe, classified, generatedFiles);
287
294
  const lock = mergeLockPackages(input.existingLock, lockEntry, ctx);
288
295
 
@@ -87,6 +87,7 @@ const ROOT_FILE_RENDERERS: Record<string, Record<string, TemplateRenderer>> = {
87
87
  workos: {
88
88
  ".env.example": workos.renderWorkosEnvExample,
89
89
  "src/policies.workos.ts": workos.renderWorkosPolicies,
90
+ "web/src/lib/workos-auth.tsx": workos.renderWorkosReactBridge,
90
91
  "workos-seed.yml": workos.renderWorkosSeedYaml,
91
92
  },
92
93
  };
@@ -186,6 +186,7 @@ export function renderWorkosAuthkit(_input: IntegrationTemplateInput): string {
186
186
  ' "forge workos doctor --yes --json",',
187
187
  ' "forge workos seed --file workos-seed.yml --dry-run --json",',
188
188
  ' "forge workos seed --file workos-seed.yml --json",',
189
+ ' "forge workos setup --real --file workos-seed.yml --json",',
189
190
  ' "forge auth check --json",',
190
191
  ' "forge auth prove --json",',
191
192
  "] as const;",
@@ -238,8 +239,9 @@ export function renderWorkosEnvExample(_input: IntegrationTemplateInput): string
238
239
  "# Forge WorkOS/AuthKit production auth example.",
239
240
  "# Secret values are intentionally blank. Keep real values in .env.local, your host secrets, or WorkOS/Vercel integration env.",
240
241
  "FORGE_AUTH_MODE=oidc",
241
- "FORGE_AUTH_ISSUER=",
242
+ "FORGE_AUTH_ISSUER=https://api.workos.com",
242
243
  "FORGE_AUTH_AUDIENCE=",
244
+ "# Set to https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID> after filling WORKOS_CLIENT_ID.",
243
245
  "FORGE_AUTH_JWKS_URI=",
244
246
  "FORGE_AUTH_ALGORITHMS=RS256",
245
247
  "",
@@ -252,12 +254,90 @@ export function renderWorkosEnvExample(_input: IntegrationTemplateInput): string
252
254
  "WORKOS_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/",
253
255
  "WORKOS_WEBHOOK_SECRET=",
254
256
  "",
257
+ "# Browser AuthKit settings for Vite/React. These are public client identifiers, not secrets.",
258
+ "VITE_WORKOS_CLIENT_ID=",
259
+ "VITE_WORKOS_REDIRECT_URI=http://localhost:5173/callback",
260
+ "",
255
261
  "# WorkOS token claims expected by ForgeOS after forge add auth workos:",
256
262
  "# sub, email, organization_id, role, roles, permissions, organization_membership_id",
257
263
  "",
258
264
  ].join("\n");
259
265
  }
260
266
 
267
+ export function renderWorkosReactBridge(_input: IntegrationTemplateInput): string {
268
+ return [
269
+ "/** Forge generated WorkOS/AuthKit React bridge.",
270
+ " * Import ForgeWorkOSAuthProvider from web/src/main.tsx, or copy the pattern into your app shell.",
271
+ " */",
272
+ 'import { AuthKitProvider, useAuth as useWorkOSAuth } from "@workos-inc/authkit-react";',
273
+ 'import { useEffect, type ReactNode } from "react";',
274
+ 'import { ForgeProvider, forgeUrl } from "./forge";',
275
+ "",
276
+ "const clientId = import.meta.env.VITE_WORKOS_CLIENT_ID as string | undefined;",
277
+ "const redirectUri = import.meta.env.VITE_WORKOS_REDIRECT_URI as string | undefined;",
278
+ "",
279
+ "export function hasWorkOSBrowserConfig(): boolean {",
280
+ " return Boolean(clientId && redirectUri);",
281
+ "}",
282
+ "",
283
+ "export function ForgeWorkOSAuthProvider({ children }: { children: ReactNode }) {",
284
+ " if (!clientId || !redirectUri) {",
285
+ " throw new Error(\"WorkOS AuthKit requires VITE_WORKOS_CLIENT_ID and VITE_WORKOS_REDIRECT_URI\");",
286
+ " }",
287
+ " return (",
288
+ " <AuthKitProvider clientId={clientId} redirectUri={redirectUri}>",
289
+ " <ForgeWorkOSBearerProvider>{children}</ForgeWorkOSBearerProvider>",
290
+ " </AuthKitProvider>",
291
+ " );",
292
+ "}",
293
+ "",
294
+ "function ForgeWorkOSBearerProvider({ children }: { children: ReactNode }) {",
295
+ " const auth = useWorkOSAuth();",
296
+ " return (",
297
+ " <ForgeProvider",
298
+ " url={forgeUrl}",
299
+ " auth={{",
300
+ " getAccessToken: async () => {",
301
+ " const token = await auth.getAccessToken();",
302
+ " return token ?? null;",
303
+ " },",
304
+ " }}",
305
+ " >",
306
+ " {children}",
307
+ " </ForgeProvider>",
308
+ " );",
309
+ "}",
310
+ "",
311
+ "export function WorkOSLoginPanel() {",
312
+ " const { isLoading, user, signIn, signUp, signOut } = useWorkOSAuth();",
313
+ " useEffect(() => {",
314
+ " if (!isLoading && !user && window.location.pathname === \"/login\") {",
315
+ " void signIn();",
316
+ " }",
317
+ " }, [isLoading, signIn, user]);",
318
+ " if (isLoading) return <p>Loading authentication...</p>;",
319
+ " if (!user && window.location.pathname === \"/login\") return <p>Redirecting...</p>;",
320
+ " if (!user) {",
321
+ " return (",
322
+ " <div>",
323
+ " <button type=\"button\" onClick={() => void signIn()}>Sign in</button>",
324
+ " <button type=\"button\" onClick={() => void signUp()}>Sign up</button>",
325
+ " </div>",
326
+ " );",
327
+ " }",
328
+ " return (",
329
+ " <div>",
330
+ " <span>{user.email}</span>",
331
+ " <button type=\"button\" onClick={() => signOut({ returnTo: window.location.origin })}>Sign out</button>",
332
+ " </div>",
333
+ " );",
334
+ "}",
335
+ "",
336
+ "export { useWorkOSAuth };",
337
+ "",
338
+ ].join("\n");
339
+ }
340
+
261
341
  export function renderWorkosFga(input: IntegrationTemplateInput): string {
262
342
  const permissions = permissionsFromApp(input);
263
343
  const roles = rolePermissions(permissions);
@@ -941,8 +1021,14 @@ export function renderWorkosSeedYaml(input: IntegrationTemplateInput): string {
941
1021
  " domains: ['globex.test']",
942
1022
  "",
943
1023
  "config:",
944
- " redirect_uris: ['http://localhost:5173/callback']",
945
- " cors_origins: ['http://localhost:5173']",
1024
+ " redirect_uris:",
1025
+ " - 'http://localhost:5173'",
1026
+ " - 'http://localhost:5173/callback'",
1027
+ " - 'http://127.0.0.1:5173'",
1028
+ " - 'http://127.0.0.1:5173/callback'",
1029
+ " cors_origins:",
1030
+ " - 'http://localhost:5173'",
1031
+ " - 'http://127.0.0.1:5173'",
946
1032
  " homepage_url: 'http://localhost:5173'",
947
1033
  " webhook_endpoints:",
948
1034
  " - url: 'http://localhost:3765/webhooks/workos'",
@@ -1064,6 +1150,7 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
1064
1150
  "## What this recipe does",
1065
1151
  "",
1066
1152
  "- Installs `@workos-inc/node` for server/action/workflow/endpoint code.",
1153
+ "- Installs `@workos-inc/authkit-react` and generates a Vite React bridge when a `web/package.json` workspace exists.",
1067
1154
  "- Registers WorkOS secret names without storing values.",
1068
1155
  "- Generates an AuthKit/FGA checklist and a `workos-seed.yml` for roles, permissions, organizations, redirect URIs, and CORS origins.",
1069
1156
  "- Generates framework-agnostic AuthKit route/session helpers for `/login`, `/callback`, `/logout`, and `/session`.",
@@ -1079,6 +1166,7 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
1079
1166
  "forge workos doctor --yes --json",
1080
1167
  "forge workos seed --file workos-seed.yml --dry-run --json",
1081
1168
  "forge workos seed --file workos-seed.yml --json",
1169
+ "forge workos setup --real --file workos-seed.yml --json",
1082
1170
  "forge auth check --json",
1083
1171
  "forge auth prove --json",
1084
1172
  "forge check --json",
@@ -1099,11 +1187,13 @@ export function renderWorkosDoc(input: IntegrationTemplateInput): string {
1099
1187
  "## Production auth mapping",
1100
1188
  "",
1101
1189
  "- `FORGE_AUTH_MODE=oidc` or `jwt`",
1102
- "- `FORGE_AUTH_ISSUER` points at the WorkOS/AuthKit issuer for the environment.",
1190
+ "- `FORGE_AUTH_ISSUER=https://api.workos.com` for hosted WorkOS AuthKit.",
1191
+ "- `FORGE_AUTH_JWKS_URI=https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>` for hosted WorkOS AuthKit.",
1103
1192
  "- `FORGE_AUTH_AUDIENCE` matches the API audience configured for the app.",
1104
1193
  "- Tenant claim should map to WorkOS `org_id` or a normalized `tenant_id` claim.",
1105
1194
  "- Role and permissions should come from WorkOS organization roles/permissions, not from dev headers.",
1106
1195
  "- Use generated `src/policies.workos.ts` as a permission-first policy template; it uses `canPermission(...)` rather than role-only checks.",
1196
+ "- `dev-headers` and frontend `devAuth` are local-only simulation paths. Real WorkOS production-like tests should use AuthKit and Bearer tokens.",
1107
1197
  "",
1108
1198
  "## AuthKit routes",
1109
1199
  "",
@@ -246,7 +246,7 @@ export const WORKOS_RECIPE: IntegrationRecipe = {
246
246
  "workos/webhook.ts",
247
247
  "workos/workos-seed.yml",
248
248
  ],
249
- rootFiles: [".env.example", "src/policies.workos.ts", "workos-seed.yml"],
249
+ rootFiles: [".env.example", "src/policies.workos.ts", "web/src/lib/workos-auth.tsx", "workos-seed.yml"],
250
250
  testkits: ["workos.mock.ts"],
251
251
  docs: ["workos.md"],
252
252
  };
@@ -1318,6 +1318,16 @@ export async function startDevServer(
1318
1318
  const aiRegistry = loadAiRegistry(workspaceRoot);
1319
1319
  const aiCheck = checkAiProviders(envStore, aiRegistry, secretRegistry);
1320
1320
  const mockAi = isMockAiEnabled({ mockAi: options.mockAi });
1321
+ const productionAuthMode = authConfig.mode === "oidc" || authConfig.mode === "jwt";
1322
+ const missingAuthConfig = [
1323
+ ...(productionAuthMode && !authConfig.issuer ? ["FORGE_AUTH_ISSUER"] : []),
1324
+ ...(productionAuthMode && !authConfig.jwksUri ? ["FORGE_AUTH_JWKS_URI"] : []),
1325
+ ];
1326
+ const workosClientId = envStore.resolve("WORKOS_CLIENT_ID") || envStore.resolve("VITE_WORKOS_CLIENT_ID");
1327
+ const suggestedWorkOSJwksUri = workosClientId
1328
+ ? `https://api.workos.com/sso/jwks/${workosClientId}`
1329
+ : "https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>";
1330
+ const authProductionReady = !productionAuthMode || missingAuthConfig.length === 0;
1321
1331
 
1322
1332
  return jsonResponse({
1323
1333
  ok: true,
@@ -1346,6 +1356,12 @@ export async function startDevServer(
1346
1356
  audienceConfigured: Boolean(authConfig.audience),
1347
1357
  jwksConfigured: Boolean(authConfig.jwksUri),
1348
1358
  requiresTenant: authConfig.requiresTenant,
1359
+ ready: authProductionReady,
1360
+ productionReady: authProductionReady,
1361
+ missing: missingAuthConfig,
1362
+ ...(missingAuthConfig.includes("FORGE_AUTH_JWKS_URI")
1363
+ ? { suggestedWorkOSJwksUri }
1364
+ : {}),
1349
1365
  },
1350
1366
  env: {
1351
1367
  loadedFiles: envStore.loadedFiles,
@@ -117,6 +117,25 @@ function looksLikeAuthFlow(text: string): boolean {
117
117
  return /sign\s*in|signin|login|logout|authkit|organization|tenant|session|workos|clerk|auth0/i.test(text);
118
118
  }
119
119
 
120
+ function hasWorkOSIntegration(workspaceRoot: string): boolean {
121
+ return nodeFileSystem.exists(join(workspaceRoot, `${GENERATED}/integrations/workos/authkit.ts`)) ||
122
+ nodeFileSystem.exists(join(workspaceRoot, `${GENERATED}/integrations/workos/auth-routes.ts`));
123
+ }
124
+
125
+ function webPackageHasAuthKit(workspaceRoot: string): boolean {
126
+ const path = join(workspaceRoot, "web/package.json");
127
+ if (!nodeFileSystem.exists(path)) return false;
128
+ try {
129
+ const packageJson = JSON.parse(nodeFileSystem.readText(path) ?? "{}") as {
130
+ dependencies?: Record<string, string>;
131
+ devDependencies?: Record<string, string>;
132
+ };
133
+ return Boolean(packageJson.dependencies?.["@workos-inc/authkit-react"] ?? packageJson.devDependencies?.["@workos-inc/authkit-react"]);
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
120
139
  function hasMainLandmark(text: string): boolean {
121
140
  return /<main[\s>]|role=["']main["']|<header[\s>]|<nav[\s>]/i.test(text);
122
141
  }
@@ -775,6 +794,10 @@ function runUiAudit(options: UiCommandOptions): UiCommandResult {
775
794
  const diagnostics: Diagnostic[] = [];
776
795
  const webSources = listWebSourceFiles(options.workspaceRoot, manifest.webRoot);
777
796
  const webText = webSources.map((source) => source.text).join("\n");
797
+ const appShellText = webSources
798
+ .filter((source) => !source.path.endsWith("/lib/workos-auth.tsx"))
799
+ .map((source) => source.text)
800
+ .join("\n");
778
801
  const scenarioRoutes = new Set(scenarios.map((scenario) => scenario.route));
779
802
  const scenarioNames = scenarios.map((scenario) => scenario.name);
780
803
  const selectorSet = new Set(manifest.selectors);
@@ -835,6 +858,18 @@ function runUiAudit(options: UiCommandOptions): UiCommandResult {
835
858
  "Tenant-scoped or production-auth app has no obvious sign-in/session/organization UI; local devAuth is not a production auth flow.",
836
859
  ));
837
860
  }
861
+ if (
862
+ hasWorkOSIntegration(options.workspaceRoot) &&
863
+ manifest.webRoot &&
864
+ webSources.length > 0 &&
865
+ (!webPackageHasAuthKit(options.workspaceRoot) || !/AuthKitProvider/.test(appShellText) || !/getAccessToken/.test(appShellText))
866
+ ) {
867
+ diagnostics.push(diagnostic(
868
+ "warning",
869
+ "FORGE_UI_WORKOS_AUTHKIT_MISSING",
870
+ "WorkOS integration is present, but the web app does not appear to mount AuthKitProvider and pass getAccessToken into ForgeProvider.",
871
+ ));
872
+ }
838
873
 
839
874
  return {
840
875
  ok: diagnostics.every((item) => item.severity !== "error"),
@@ -1,3 +1,3 @@
1
- export const FORGEOS_VERSION = "0.1.0-alpha.39";
1
+ export const FORGEOS_VERSION = "0.1.0-alpha.40";
2
2
  export const GENERATOR_VERSION = FORGEOS_VERSION;
3
3
  export const CLI_VERSION = FORGEOS_VERSION;