forgeos 0.1.0-alpha.55 → 0.1.0-alpha.57

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.
@@ -336,6 +336,7 @@ export type ForgeCommand =
336
336
  webOnly: boolean;
337
337
  open: boolean;
338
338
  webPort?: number;
339
+ publicApiUrl?: string;
339
340
  telemetry: string[];
340
341
  envFile?: string;
341
342
  skipStartupConsole: boolean;
@@ -418,6 +419,7 @@ export type ForgeCommand =
418
419
  subcommand: EnvSubcommand;
419
420
  json: boolean;
420
421
  redacted: boolean;
422
+ target?: "local" | "staging" | "production";
421
423
  workspaceRoot: string;
422
424
  }
423
425
  | {
@@ -602,7 +604,7 @@ const BASELINE_SUBCOMMANDS: BaselineSubcommand[] = ["create", "status"];
602
604
  const AUTHMD_SUBCOMMANDS: AuthMdSubcommand[] = ["generate", "check"];
603
605
  const WORKOS_SUBCOMMANDS: WorkOSSubcommand[] = ["install", "doctor", "seed", "setup", "prove", "fga"];
604
606
  const WORKOS_FGA_ACTIONS: WorkOSFgaAction[] = ["plan", "sync", "prove", "doctor"];
605
- const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "check", "render", "package", "verify"];
607
+ const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "init", "check", "readiness", "render", "package", "verify"];
606
608
  const FIELD_TEST_SUBCOMMANDS: FieldTestSubcommand[] = ["create", "run", "report"];
607
609
  const SECURITY_SUBCOMMANDS: SecuritySubcommand[] = ["prove"];
608
610
  const RLS_SUBCOMMANDS: RlsSubcommand[] = ["generate", "check", "apply", "test", "mutate-test"];
@@ -1484,10 +1486,10 @@ export function parseCli(argv: string[]): ParsedCli {
1484
1486
  case "deploy": {
1485
1487
  const subcommand = rest[0] as DeploySubcommand | undefined;
1486
1488
  if (!subcommand || !DEPLOY_SUBCOMMANDS.includes(subcommand)) {
1487
- errors.push("forge deploy requires subcommand: plan, check, render, package, or verify");
1489
+ errors.push("forge deploy requires subcommand: plan, init, check, readiness, render, package, or verify");
1488
1490
  return { command: null, workspaceRoot, errors };
1489
1491
  }
1490
- const targetRaw = parseOptionValue(argv, "--target") ?? (subcommand === "render" || subcommand === "package" ? rest[1] : undefined) ?? "docker";
1492
+ const targetRaw = parseOptionValue(argv, "--target") ?? (subcommand === "init" || subcommand === "render" || subcommand === "package" ? rest[1] : undefined) ?? "docker";
1491
1493
  if (targetRaw !== "docker" && targetRaw !== "forge-cloud") {
1492
1494
  errors.push("forge deploy --target must be docker or forge-cloud");
1493
1495
  }
@@ -2732,6 +2734,7 @@ export function parseCli(argv: string[]): ParsedCli {
2732
2734
  webOnly: parseFlag(argv, "--web-only"),
2733
2735
  open: parseFlag(argv, "--open"),
2734
2736
  webPort,
2737
+ publicApiUrl: parseOptionValue(argv, "--public-api-url"),
2735
2738
  telemetry: (parseOptionValue(argv, "--telemetry") ?? "local")
2736
2739
  .split(",")
2737
2740
  .map((value) => value.trim())
@@ -2974,10 +2977,14 @@ export function parseCli(argv: string[]): ParsedCli {
2974
2977
  }
2975
2978
  case "env": {
2976
2979
  const subcommand = rest[0] as EnvSubcommand | undefined;
2977
- if (!subcommand || !["list", "check", "print"].includes(subcommand)) {
2978
- errors.push("forge env requires subcommand: list, check, or print");
2980
+ if (!subcommand || !["list", "check", "print", "doctor"].includes(subcommand)) {
2981
+ errors.push("forge env requires subcommand: list, check, print, or doctor");
2979
2982
  return { command: null, workspaceRoot, errors };
2980
2983
  }
2984
+ const targetRaw = parseOptionValue(argv, "--target") ?? "local";
2985
+ if (!["local", "staging", "production"].includes(targetRaw)) {
2986
+ errors.push("forge env --target must be local, staging, or production");
2987
+ }
2981
2988
 
2982
2989
  return {
2983
2990
  command: {
@@ -2985,6 +2992,7 @@ export function parseCli(argv: string[]): ParsedCli {
2985
2992
  subcommand,
2986
2993
  json: parseFlag(argv, "--json"),
2987
2994
  redacted: parseFlag(argv, "--redacted"),
2995
+ target: targetRaw as "local" | "staging" | "production",
2988
2996
  workspaceRoot,
2989
2997
  },
2990
2998
  workspaceRoot,
@@ -3244,6 +3252,7 @@ export function hasUnknownOption(argv: string[]): string | null {
3244
3252
  "--postgres-version",
3245
3253
  "--runtime-port",
3246
3254
  "--web-port",
3255
+ "--public-api-url",
3247
3256
  "--preview-port",
3248
3257
  "--preview-url",
3249
3258
  "--studio-url",
@@ -3371,6 +3380,7 @@ export function hasUnknownOption(argv: string[]): string | null {
3371
3380
  arg === "--postgres-version" ||
3372
3381
  arg === "--runtime-port" ||
3373
3382
  arg === "--web-port" ||
3383
+ arg === "--public-api-url" ||
3374
3384
  arg === "--preview-port" ||
3375
3385
  arg === "--preview-url" ||
3376
3386
  arg === "--studio-url" ||
@@ -219,13 +219,141 @@ export function formatSecretsJson(result: SecretsCommandResult): string {
219
219
  return `${JSON.stringify(result, null, 2)}\n`;
220
220
  }
221
221
 
222
- export type EnvSubcommand = "list" | "check" | "print";
222
+ export type EnvSubcommand = "list" | "check" | "print" | "doctor";
223
+ export type EnvDoctorTarget = "local" | "staging" | "production";
223
224
 
224
225
  export interface EnvCommandOptions {
225
226
  subcommand: EnvSubcommand;
226
227
  workspaceRoot: string;
227
228
  json: boolean;
228
229
  redacted?: boolean;
230
+ target?: EnvDoctorTarget;
231
+ }
232
+
233
+ const ENV_DOCTOR_KEYS = [
234
+ "DATABASE_URL",
235
+ "FORGE_AUTH_MODE",
236
+ "FORGE_AUTH_ISSUER",
237
+ "FORGE_AUTH_AUDIENCE",
238
+ "FORGE_AUTH_JWKS_URI",
239
+ "FORGE_AUTH_DISCOVERY_URL",
240
+ "WORKOS_API_KEY",
241
+ "WORKOS_CLIENT_ID",
242
+ "WORKOS_COOKIE_PASSWORD",
243
+ "WORKOS_REDIRECT_URI",
244
+ "WORKOS_POST_LOGIN_REDIRECT_URI",
245
+ "WORKOS_POST_LOGOUT_REDIRECT_URI",
246
+ "WORKOS_WEBHOOK_SECRET",
247
+ ] as const;
248
+
249
+ function parseEnvFile(workspaceRoot: string, relative: string): Record<string, string> {
250
+ const path = join(workspaceRoot, relative);
251
+ if (!nodeFileSystem.exists(path)) return {};
252
+ const values: Record<string, string> = {};
253
+ for (const rawLine of (nodeFileSystem.readText(path) ?? "").split(/\r?\n/)) {
254
+ const line = rawLine.trim();
255
+ if (!line || line.startsWith("#")) continue;
256
+ const eq = line.indexOf("=");
257
+ if (eq <= 0) continue;
258
+ const rawValue = line.slice(eq + 1).trim();
259
+ values[line.slice(0, eq).trim()] = rawValue.replace(/^["']|["']$/g, "");
260
+ }
261
+ return values;
262
+ }
263
+
264
+ function envDoctorFiles(target: EnvDoctorTarget): string[] {
265
+ if (target === "production") return ["deploy/.env.production"];
266
+ if (target === "staging") return ["deploy/.env.staging", ".env.staging"];
267
+ return [".env", ".env.local"];
268
+ }
269
+
270
+ function hasWorkOSAppArtifacts(workspaceRoot: string): boolean {
271
+ return nodeFileSystem.exists(join(workspaceRoot, "workos-seed.yml")) ||
272
+ nodeFileSystem.exists(join(workspaceRoot, "src/policies.workos.ts")) ||
273
+ nodeFileSystem.exists(join(workspaceRoot, "src/forge/_generated/integrations/workos/auth-routes.ts"));
274
+ }
275
+
276
+ function envDoctor(options: EnvCommandOptions): SecretsCommandResult {
277
+ const target = options.target ?? "local";
278
+ const files = envDoctorFiles(target).map((path) => {
279
+ const values = parseEnvFile(options.workspaceRoot, path);
280
+ return {
281
+ path,
282
+ present: nodeFileSystem.exists(join(options.workspaceRoot, path)),
283
+ keys: Object.keys(values).filter((key) => ENV_DOCTOR_KEYS.includes(key as (typeof ENV_DOCTOR_KEYS)[number])).sort(),
284
+ values,
285
+ };
286
+ });
287
+ const processValues = Object.fromEntries(
288
+ ENV_DOCTOR_KEYS.filter((key) => Boolean(process.env[key])).map((key) => [key, process.env[key]!]),
289
+ );
290
+ const effective = {
291
+ ...Object.assign({}, ...files.map((file) => file.values)),
292
+ ...processValues,
293
+ } as Record<string, string>;
294
+ const required = target === "production"
295
+ ? ["DATABASE_URL", "FORGE_AUTH_MODE", "FORGE_AUTH_ISSUER", "FORGE_AUTH_AUDIENCE"]
296
+ : ["FORGE_AUTH_MODE"];
297
+ const missing = required.filter((key) => !effective[key]);
298
+ const authMode = effective.FORGE_AUTH_MODE ?? "dev-headers";
299
+ const productionAuth = authMode === "jwt" || authMode === "oidc";
300
+ const database = effective.DATABASE_URL
301
+ ? "postgres"
302
+ : target === "production"
303
+ ? "missing"
304
+ : "local-dev";
305
+ const workosDetected = hasWorkOSAppArtifacts(options.workspaceRoot) ||
306
+ Boolean(effective.WORKOS_CLIENT_ID || effective.WORKOS_API_KEY);
307
+ const provider = workosDetected ? "workos" : "none";
308
+ const hasJwtSource = Boolean(effective.FORGE_AUTH_JWKS_URI || effective.FORGE_AUTH_DISCOVERY_URL);
309
+ const blockers = [
310
+ ...missing.filter((key) => key !== "DATABASE_URL").map((key) => `${key} missing`),
311
+ ...(target === "production" && !productionAuth ? [`FORGE_AUTH_MODE=${authMode} is not production auth`] : []),
312
+ ...(target === "production" && database === "missing" ? ["DATABASE_URL missing"] : []),
313
+ ...(target === "production" && productionAuth && !hasJwtSource
314
+ ? ["FORGE_AUTH_JWKS_URI or FORGE_AUTH_DISCOVERY_URL missing"]
315
+ : []),
316
+ ...(target === "production" && workosDetected && !effective.WORKOS_CLIENT_ID ? ["WORKOS_CLIENT_ID missing"] : []),
317
+ ...(target === "production" && workosDetected && !effective.WORKOS_API_KEY ? ["WORKOS_API_KEY missing"] : []),
318
+ ];
319
+ const warnings = [
320
+ ...(target === "production" && files.every((file) => !file.present)
321
+ ? ["deploy/.env.production not found; process.env alone may be fine in CI but is easy for agents to miss"]
322
+ : []),
323
+ ...(workosDetected && !effective.WORKOS_COOKIE_PASSWORD ? ["WORKOS_COOKIE_PASSWORD missing"] : []),
324
+ ...(workosDetected && !effective.WORKOS_REDIRECT_URI ? ["WORKOS_REDIRECT_URI missing"] : []),
325
+ ];
326
+ return {
327
+ exitCode: blockers.length === 0 ? 0 : 1,
328
+ data: {
329
+ schemaVersion: "0.1.0",
330
+ kind: "env-doctor",
331
+ ok: blockers.length === 0,
332
+ target,
333
+ authMode,
334
+ productionAuth,
335
+ database,
336
+ provider,
337
+ sources: [
338
+ {
339
+ path: "process.env",
340
+ present: Object.keys(processValues).length > 0,
341
+ keys: Object.keys(processValues).sort(),
342
+ values: undefined,
343
+ },
344
+ ...files.map(({ path, present, keys }) => ({ path, present, keys })),
345
+ ],
346
+ present: ENV_DOCTOR_KEYS.filter((key) => Boolean(effective[key])).sort(),
347
+ missing,
348
+ blockers,
349
+ warnings,
350
+ nextActions: blockers.length === 0
351
+ ? ["forge deploy readiness --production --json"]
352
+ : target === "production"
353
+ ? ["cp deploy/.env.production.example deploy/.env.production", "forge env doctor --target production --json"]
354
+ : ["forge env doctor --target local --json"],
355
+ },
356
+ };
229
357
  }
230
358
 
231
359
  export async function runEnvCommand(options: EnvCommandOptions): Promise<SecretsCommandResult> {
@@ -233,6 +361,8 @@ export async function runEnvCommand(options: EnvCommandOptions): Promise<Secrets
233
361
  const schema = loadEnvSchema(options.workspaceRoot);
234
362
 
235
363
  switch (options.subcommand) {
364
+ case "doctor":
365
+ return envDoctor(options);
236
366
  case "list":
237
367
  if (!schema) {
238
368
  return {
@@ -573,6 +573,62 @@ function collectWorkOSRealEnvChecks(workspaceRoot: string, cliAuth?: WorkOSCliAu
573
573
  ];
574
574
  }
575
575
 
576
+ function renderMergedDotEnv(current: string | null, values: Record<string, string>): string {
577
+ const lines = (current ?? "").split(/\r?\n/);
578
+ const seen = new Set<string>();
579
+ const next = lines.map((line) => {
580
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
581
+ if (!match || !(match[1]! in values)) {
582
+ return line;
583
+ }
584
+ seen.add(match[1]!);
585
+ return `${match[1]}=${values[match[1]!]}`;
586
+ });
587
+ for (const [key, value] of Object.entries(values)) {
588
+ if (!seen.has(key)) {
589
+ next.push(`${key}=${value}`);
590
+ }
591
+ }
592
+ return `${next.filter((line, index, all) => line.length > 0 || index < all.length - 1).join("\n").trimEnd()}\n`;
593
+ }
594
+
595
+ function syncWorkOSPublicWebEnv(workspaceRoot: string, write: boolean): { ok: boolean; changed: string[]; path?: string; skipped?: string } {
596
+ const frontendWorkspace = findWorkOSFrontendWorkspace(workspaceRoot);
597
+ if (!frontendWorkspace) {
598
+ return { ok: true, changed: [], skipped: "no web workspace detected" };
599
+ }
600
+ const env = readRealEnv(workspaceRoot);
601
+ const clientId = env.VITE_WORKOS_CLIENT_ID || env.WORKOS_CLIENT_ID || "";
602
+ const redirectUri = env.VITE_WORKOS_REDIRECT_URI || env.WORKOS_REDIRECT_URI || "http://localhost:5173/callback";
603
+ const values: Record<string, string> = {
604
+ VITE_FORGE_URL: env.VITE_FORGE_URL || "http://localhost:3765",
605
+ VITE_WORKOS_CLIENT_ID: clientId,
606
+ VITE_WORKOS_REDIRECT_URI: redirectUri,
607
+ };
608
+ const relPath = join(frontendWorkspace, ".env.local");
609
+ const path = join(workspaceRoot, relPath);
610
+ const current = readRawText(workspaceRoot, relPath);
611
+ const next = renderMergedDotEnv(current, values);
612
+ if (current === next) {
613
+ return { ok: true, changed: [], path: relPath };
614
+ }
615
+ if (write) {
616
+ mkdirSync(dirname(path), { recursive: true });
617
+ writeFileSync(path, next, "utf8");
618
+ }
619
+ return { ok: true, changed: [relPath], path: relPath };
620
+ }
621
+
622
+ function findWorkOSFrontendWorkspace(workspaceRoot: string): string | undefined {
623
+ for (const candidate of ["web", "frontend", "client", "apps/web", "packages/web"]) {
624
+ if (existsSync(join(workspaceRoot, candidate, "package.json"))) {
625
+ return candidate;
626
+ }
627
+ }
628
+ return undefined;
629
+ }
630
+
631
+
576
632
  export function collectPolicyPermissions(workspaceRoot: string): string[] {
577
633
  const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
578
634
  policies?: Array<{ permissions?: string[] }>;
@@ -1757,12 +1813,10 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1757
1813
  readText(workspaceRoot, "web/src/main.tsx"),
1758
1814
  readText(workspaceRoot, "web/src/App.tsx"),
1759
1815
  ].join("\n");
1760
- const appShellUsesWorkOSProvider =
1761
- frontendAppShell.includes("ForgeWorkOSAuthProvider") ||
1762
- frontendAppShell.includes("AuthKitProvider");
1763
- const authBridgeProvidesToken =
1764
- generatedFrontendAuthBridge.includes("getToken") ||
1765
- generatedFrontendAuthBridge.includes("getAccessToken");
1816
+ const appShellUsesWorkOSProvider = frontendAppShell.includes("ForgeWorkOSAuthProvider");
1817
+ const authBridgeUsesBackendRoutes =
1818
+ generatedFrontendAuthBridge.includes("workOSApiUrl") &&
1819
+ ["/login", "/logout", "/session"].every((route) => generatedFrontendAuthBridge.includes(route));
1766
1820
  const authBridgeProvidesSessionClaims =
1767
1821
  includesAll(generatedFrontendAuthBridge, ["useForgeWorkOSSession", "/session", "claims"]);
1768
1822
  const authSessionProxyConfigured = webAuthSessionProxyConfigured(workspaceRoot);
@@ -1809,7 +1863,7 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1809
1863
  ok: !hasWeb || (hasValue(realEnv, "VITE_WORKOS_CLIENT_ID") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_CLIENT_ID=")) &&
1810
1864
  (hasValue(realEnv, "VITE_WORKOS_REDIRECT_URI") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_REDIRECT_URI=")),
1811
1865
  detail: hasWeb
1812
- ? "web workspace has VITE_WORKOS_CLIENT_ID and VITE_WORKOS_REDIRECT_URI guidance for AuthKit React"
1866
+ ? "web workspace has browser WorkOS client ID and redirect URI guidance for backend-owned AuthKit routes"
1813
1867
  : "no web workspace detected",
1814
1868
  },
1815
1869
  {
@@ -1821,11 +1875,11 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1821
1875
  },
1822
1876
  {
1823
1877
  name: "browser-authkit-bridge",
1824
- ok: !hasWeb || includesAll(generatedFrontendAuthBridge, ["AuthKitProvider", "ForgeProvider"]) &&
1825
- authBridgeProvidesToken &&
1878
+ ok: !hasWeb || generatedFrontendAuthBridge.includes("ForgeProvider") &&
1879
+ authBridgeUsesBackendRoutes &&
1826
1880
  authBridgeProvidesSessionClaims,
1827
1881
  detail: hasWeb
1828
- ? "generated web/src/lib/workos-auth.tsx bridge provides AuthKitProvider, ForgeProvider token wiring, and normalized /session claims"
1882
+ ? "generated web/src/lib/workos-auth.tsx bridge uses backend-owned AuthKit routes, ForgeProvider, and normalized /session claims"
1829
1883
  : "no web workspace detected",
1830
1884
  },
1831
1885
  {
@@ -1841,7 +1895,7 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
1841
1895
  name: "browser-authkit-provider",
1842
1896
  ok: !hasWeb || appShellUsesWorkOSProvider,
1843
1897
  detail: hasWeb
1844
- ? "web app shell mounts ForgeWorkOSAuthProvider or AuthKitProvider"
1898
+ ? "web app shell mounts ForgeWorkOSAuthProvider"
1845
1899
  : "no web workspace detected",
1846
1900
  },
1847
1901
  {
@@ -2486,6 +2540,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2486
2540
  const seed = parseSeedFile(options.workspaceRoot, file);
2487
2541
  const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
2488
2542
  const setupDryRun = options.dryRun || !options.real;
2543
+ const publicEnvSync = syncWorkOSPublicWebEnv(options.workspaceRoot, localOk && !setupDryRun);
2489
2544
  const configActions = runWorkOSConfigActions(seed, options, true);
2490
2545
  const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
2491
2546
  if (!localOk) {
@@ -2500,6 +2555,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2500
2555
  real: options.real ?? false,
2501
2556
  seed,
2502
2557
  seedState,
2558
+ publicEnvSync,
2503
2559
  ...(cliAuth ? { cliAuth } : {}),
2504
2560
  configActions,
2505
2561
  nextCommand: cliAuth && !cliAuth.ok
@@ -2521,6 +2577,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2521
2577
  real: false,
2522
2578
  seed,
2523
2579
  seedState,
2580
+ publicEnvSync,
2524
2581
  ...(cliAuth ? { cliAuth } : {}),
2525
2582
  configActions,
2526
2583
  nextCommand: `forge workos setup --real --file ${file} --json`,
@@ -2546,6 +2603,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
2546
2603
  data: {
2547
2604
  real: true,
2548
2605
  seed,
2606
+ publicEnvSync,
2549
2607
  ...(cliAuth ? { cliAuth } : {}),
2550
2608
  seedState: seedResultData.seedState ?? readWorkOSSeedState(options.workspaceRoot, seed),
2551
2609
  seedResult: seedResult.data,
@@ -31,6 +31,7 @@ import {
31
31
  resolveByPackageName,
32
32
  resolveRecipe,
33
33
  } from "../recipes/registry.ts";
34
+ import { applyWorkOSRecipeProfile } from "../recipes/profiles.ts";
34
35
  import { discover } from "../orchestrator/discover.ts";
35
36
  import {
36
37
  loadManifest,
@@ -92,24 +93,6 @@ function recipeResultMetadata(recipe: IntegrationRecipe): Pick<
92
93
  };
93
94
  }
94
95
 
95
- const WORKOS_FGA_INTEGRATION_FILES = new Set([
96
- "workos/fga.ts",
97
- "workos/resource-map.ts",
98
- ]);
99
-
100
- function applyWorkOSRecipeProfile(
101
- recipe: NonNullable<ReturnType<typeof resolveRecipe>>,
102
- options: Pick<ForgeAddOptions, "withFga">,
103
- ): IntegrationRecipe {
104
- if (recipe.alias !== "workos" || options.withFga) {
105
- return recipe;
106
- }
107
- return {
108
- ...recipe,
109
- integrations: recipe.integrations?.filter((file) => !WORKOS_FGA_INTEGRATION_FILES.has(file)),
110
- };
111
- }
112
-
113
96
  function addExplanation(result: ForgeAddResult): string {
114
97
  if (result.mode === "package") {
115
98
  const location = result.target && result.target !== "root" ? `${result.target}/package.json` : "package.json";
@@ -528,6 +511,60 @@ function workosFrontendWorkspace(workspaceRoot: string): string | undefined {
528
511
  return findFrontendWorkspace(workspaceRoot);
529
512
  }
530
513
 
514
+ function parseDotEnv(text: string | null): Record<string, string> {
515
+ const result: Record<string, string> = {};
516
+ for (const line of (text ?? "").split(/\r?\n/)) {
517
+ const trimmed = line.trim();
518
+ if (!trimmed || trimmed.startsWith("#")) continue;
519
+ const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
520
+ if (!match) continue;
521
+ result[match[1]!] = match[2]!.replace(/^["']|["']$/g, "");
522
+ }
523
+ return result;
524
+ }
525
+
526
+ function renderMergedDotEnv(current: string | null, values: Record<string, string>): string {
527
+ const lines = (current ?? "").split(/\r?\n/);
528
+ const seen = new Set<string>();
529
+ const next = lines.map((line) => {
530
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
531
+ if (!match || !(match[1]! in values)) {
532
+ return line;
533
+ }
534
+ seen.add(match[1]!);
535
+ return `${match[1]}=${values[match[1]!]}`;
536
+ });
537
+ for (const [key, value] of Object.entries(values)) {
538
+ if (!seen.has(key)) {
539
+ next.push(`${key}=${value}`);
540
+ }
541
+ }
542
+ return `${next.filter((line, index, all) => line.length > 0 || index < all.length - 1).join("\n").trimEnd()}\n`;
543
+ }
544
+
545
+ function syncWorkOSPublicWebEnv(workspaceRoot: string, frontendWorkspace: string): { changed: string[] } {
546
+ const rootLocal = parseDotEnv(nodeFileSystem.readText(join(workspaceRoot, ".env.local")));
547
+ const rootExample = parseDotEnv(nodeFileSystem.readText(join(workspaceRoot, ".env.example")));
548
+ const source = { ...rootExample, ...rootLocal };
549
+ const clientId = source.VITE_WORKOS_CLIENT_ID || source.WORKOS_CLIENT_ID || "";
550
+ const redirectUri = source.VITE_WORKOS_REDIRECT_URI || source.WORKOS_REDIRECT_URI || "http://localhost:5173/callback";
551
+ const values: Record<string, string> = {
552
+ VITE_FORGE_URL: source.VITE_FORGE_URL || "http://localhost:3765",
553
+ VITE_WORKOS_CLIENT_ID: clientId,
554
+ VITE_WORKOS_REDIRECT_URI: redirectUri,
555
+ };
556
+ const envRel = `${frontendWorkspace}/.env.local`;
557
+ const envPath = join(workspaceRoot, envRel);
558
+ const current = nodeFileSystem.readText(envPath);
559
+ const next = renderMergedDotEnv(current, values);
560
+ if (current === next) {
561
+ return { changed: [] };
562
+ }
563
+ nodeFileSystem.mkdirp(join(workspaceRoot, frontendWorkspace));
564
+ nodeFileSystem.writeText(envPath, next);
565
+ return { changed: [envRel] };
566
+ }
567
+
531
568
  function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string): {
532
569
  changed: string[];
533
570
  warnings: Diagnostic[];
@@ -610,7 +647,7 @@ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string
610
647
  " <p className=\"notice error\">{workosSession.error.message}</p>",
611
648
  " <div className=\"login-form\">",
612
649
  " <button type=\"button\" onClick={() => void workosSession.refresh()}>Retry session</button>",
613
- " <button className=\"secondary\" type=\"button\" onClick={() => auth.signOut({ returnTo: window.location.origin })}>Sign out</button>",
650
+ " <button className=\"secondary\" type=\"button\" onClick={() => auth.signOut()}>Sign out</button>",
614
651
  " </div>",
615
652
  " </section>",
616
653
  " </main>",
@@ -631,8 +668,8 @@ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string
631
668
  " <p className=\"eyebrow\">WorkOS AuthKit</p>",
632
669
  " <h1>Sign in to review vendor access</h1>",
633
670
  " <div className=\"login-form\">",
634
- " <button type=\"button\" onClick={() => void auth.signIn()}>Sign in with WorkOS</button>",
635
- " <button className=\"secondary\" type=\"button\" onClick={() => void auth.signUp()}>Create account</button>",
671
+ " <button type=\"button\" onClick={() => auth.signIn()}>Sign in with WorkOS</button>",
672
+ " <button className=\"secondary\" type=\"button\" onClick={() => auth.signUp()}>Create account</button>",
636
673
  " </div>",
637
674
  " </section>",
638
675
  " <aside className=\"login-context\">",
@@ -664,7 +701,7 @@ function connectWorkOSReactRoot(workspaceRoot: string, frontendWorkspace: string
664
701
  " persona={persona}",
665
702
  " personas={[persona]}",
666
703
  " onPersonaChange={() => undefined}",
667
- " onSignOut={() => auth.signOut({ returnTo: window.location.origin })}",
704
+ " onSignOut={() => auth.signOut()}",
668
705
  " />",
669
706
  " );",
670
707
  "}",
@@ -1103,7 +1140,11 @@ export async function forgeAdd(
1103
1140
  });
1104
1141
 
1105
1142
  const workosWeb = normalized === "workos" && frontendWorkspace
1106
- ? connectWorkOSReactRoot(options.workspaceRoot, frontendWorkspace)
1143
+ ? (() => {
1144
+ const root = connectWorkOSReactRoot(options.workspaceRoot, frontendWorkspace);
1145
+ const env = syncWorkOSPublicWebEnv(options.workspaceRoot, frontendWorkspace);
1146
+ return { changed: [...root.changed, ...env.changed], warnings: root.warnings };
1147
+ })()
1107
1148
  : { changed: [] as string[], warnings: [] as Diagnostic[] };
1108
1149
 
1109
1150
  const warningsCombined = [...preinstalledWarnings, ...warnings, ...emitResult.warnings, ...workosWeb.warnings];