forgeos 0.1.0-alpha.55 → 0.1.0-alpha.56
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 +25 -0
- package/docs/changelog.md +16 -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/commands.ts +1 -0
- package/src/forge/cli/deploy.ts +169 -22
- package/src/forge/cli/main.ts +4 -1
- package/src/forge/cli/parse.ts +11 -5
- package/src/forge/cli/secrets.ts +131 -1
- package/src/forge/version.ts +1 -1
package/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.56 input=a013874c7856b533aae64f467111c45bd707703fd9db6f0b8115898afc97af53 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.56
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add the production deploy golden path as an explicit CLI workflow.
|
|
8
|
+
|
|
9
|
+
- `forge deploy init --target docker` now acts as the canonical Docker
|
|
10
|
+
production bootstrap command for runtime files, `deploy/.env.production`
|
|
11
|
+
guidance, and production handoff docs.
|
|
12
|
+
- `forge deploy readiness --production --json` now summarizes whether an app
|
|
13
|
+
can publish, grouped blockers/warnings, readiness score, and the next
|
|
14
|
+
command an agent should run.
|
|
15
|
+
- `forge env doctor --target local|staging|production --json` now reports the
|
|
16
|
+
effective auth mode, database posture, provider detection, missing env
|
|
17
|
+
names, and safe source metadata without printing secret values.
|
|
18
|
+
- `forge deploy check --production --json` now includes blocking/warning
|
|
19
|
+
summaries and the readiness object used by the deploy gate.
|
|
20
|
+
- `forge deploy verify --production --url ... --json` now validates required
|
|
21
|
+
public auth metadata and probes optional runtime endpoints such as
|
|
22
|
+
`/ready`, `/live/status`, `/outbox/status`, and `/webhooks/workos`.
|
|
23
|
+
- Production docs now present the canonical flow:
|
|
24
|
+
`deploy init -> env doctor -> auth/auth.md/WorkOS proof -> field-test ->
|
|
25
|
+
readiness -> check -> package -> verify`, while keeping WorkOS FGA
|
|
26
|
+
explicitly optional unless the app opts into FGA artifacts.
|
|
27
|
+
|
|
3
28
|
## 0.1.0-alpha.55
|
|
4
29
|
|
|
5
30
|
### Patch Changes
|
package/docs/changelog.md
CHANGED
|
@@ -6,6 +6,22 @@ The canonical source file in the repository is `CHANGELOG.md`.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.1.0-alpha.56
|
|
10
|
+
|
|
11
|
+
- Added `forge deploy init --target docker` as the canonical production Docker
|
|
12
|
+
bootstrap command.
|
|
13
|
+
- Added `forge deploy readiness --production --json` to answer whether an app
|
|
14
|
+
can publish, what blocks production, and which command should run next.
|
|
15
|
+
- Added `forge env doctor --target local|staging|production --json` for safe,
|
|
16
|
+
target-specific env source reporting without exposing secret values.
|
|
17
|
+
- `forge deploy check --production --json` now includes grouped
|
|
18
|
+
blockers/warnings and a readiness summary for agent handoffs.
|
|
19
|
+
- `forge deploy verify --production --url ... --json` now validates required
|
|
20
|
+
public auth metadata and optional operational endpoints.
|
|
21
|
+
- Production docs now describe the canonical flow from deploy init through
|
|
22
|
+
field-test, readiness, package, and verify, with WorkOS FGA kept optional
|
|
23
|
+
unless the app opts into FGA artifacts.
|
|
24
|
+
|
|
9
25
|
## 0.1.0-alpha.55
|
|
10
26
|
|
|
11
27
|
- `forge add auth workos` now defaults to AuthKit/RBAC, permission claims,
|
package/package.json
CHANGED
|
@@ -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.
|
|
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.56","releaseId":"forgeos@0.1.0-alpha.56+unknown","schemaVersion":"0.1.0"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.56 input=a013874c7856b533aae64f467111c45bd707703fd9db6f0b8115898afc97af53 content=a9688092f903ceb0263f9581e3ab71ecc968284ffd530d9112a5467ea10baf63
|
|
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.
|
|
23
|
-
"releaseId": "forgeos@0.1.0-alpha.
|
|
22
|
+
"packageVersion": "0.1.0-alpha.56",
|
|
23
|
+
"releaseId": "forgeos@0.1.0-alpha.56+unknown",
|
|
24
24
|
"schemaVersion": "0.1.0"
|
|
25
25
|
} as const;
|
|
@@ -2863,6 +2863,7 @@ export async function executeCommand(command: ForgeCommand): Promise<number> {
|
|
|
2863
2863
|
workspaceRoot: command.workspaceRoot,
|
|
2864
2864
|
json: command.json,
|
|
2865
2865
|
redacted: command.redacted,
|
|
2866
|
+
target: command.target,
|
|
2866
2867
|
});
|
|
2867
2868
|
|
|
2868
2869
|
if (command.json) {
|
package/src/forge/cli/deploy.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { runAuthCommand } from "./auth.ts";
|
|
|
13
13
|
import { runAuthMdCommand } from "./authmd.ts";
|
|
14
14
|
import { runWorkOSCommand } from "./workos.ts";
|
|
15
15
|
|
|
16
|
-
export type DeploySubcommand = "plan" | "check" | "render" | "package" | "verify";
|
|
16
|
+
export type DeploySubcommand = "plan" | "init" | "check" | "readiness" | "render" | "package" | "verify";
|
|
17
17
|
export type DeployTarget = "docker" | "forge-cloud";
|
|
18
18
|
|
|
19
19
|
export interface DeployCommandOptions {
|
|
@@ -42,6 +42,8 @@ export interface DeployCommandResult {
|
|
|
42
42
|
target: DeployTarget;
|
|
43
43
|
production: boolean;
|
|
44
44
|
checks: DeployCheck[];
|
|
45
|
+
blocking?: string[];
|
|
46
|
+
warnings?: string[];
|
|
45
47
|
files?: string[];
|
|
46
48
|
plan?: {
|
|
47
49
|
summary: string;
|
|
@@ -50,6 +52,7 @@ export interface DeployCommandResult {
|
|
|
50
52
|
notes: string[];
|
|
51
53
|
};
|
|
52
54
|
probes?: Array<{ method: string; url: string; ok: boolean; status?: number; contentType?: string; error?: string; jsonValid?: boolean }>;
|
|
55
|
+
readiness?: DeployReadinessSummary;
|
|
53
56
|
nextActions: string[];
|
|
54
57
|
exitCode: 0 | 1;
|
|
55
58
|
}
|
|
@@ -57,6 +60,27 @@ export interface DeployCommandResult {
|
|
|
57
60
|
interface DeployProbeExpectation {
|
|
58
61
|
contentTypeIncludes?: string;
|
|
59
62
|
json?: boolean;
|
|
63
|
+
required?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DeployReadinessSummary {
|
|
67
|
+
score: number;
|
|
68
|
+
status: "ready" | "blocked" | "warning";
|
|
69
|
+
blocking: string[];
|
|
70
|
+
warnings: string[];
|
|
71
|
+
stages: Array<{
|
|
72
|
+
name: string;
|
|
73
|
+
ok: boolean;
|
|
74
|
+
score: number;
|
|
75
|
+
checks: string[];
|
|
76
|
+
blocking: string[];
|
|
77
|
+
warnings: string[];
|
|
78
|
+
}>;
|
|
79
|
+
answers: {
|
|
80
|
+
canPublish: boolean;
|
|
81
|
+
ifNotWhatIsMissing: string[];
|
|
82
|
+
nextCommand: string | null;
|
|
83
|
+
};
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
function readGeneratedJson<T>(workspaceRoot: string, relative: string): T | null {
|
|
@@ -93,6 +117,74 @@ function unique(values: string[]): string[] {
|
|
|
93
117
|
return [...new Set(values)];
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
function deployStageForCheck(name: string): string {
|
|
121
|
+
if (["generated", "package-lockfile", "frontend-build-script"].includes(name)) return "build";
|
|
122
|
+
if (["database-url"].includes(name)) return "database";
|
|
123
|
+
if (name.startsWith("auth-") || ["production-auth-mode", "auth-issuer", "auth-audience", "auth-jwks", "tenant-claim"].includes(name)) return "auth";
|
|
124
|
+
if (name.startsWith("workos")) return "workos";
|
|
125
|
+
if (name.startsWith("auth-md") || name === "oauth-protected-resource") return "auth metadata";
|
|
126
|
+
if (name.startsWith("field-test")) return "field test";
|
|
127
|
+
if (name === "live-production") return "live/runtime";
|
|
128
|
+
if (name === "deploy-env-sources") return "environment";
|
|
129
|
+
return "production";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function summarizeDeployReadiness(checks: DeployCheck[], nextActions: string[]): DeployReadinessSummary {
|
|
133
|
+
const blocking = checks
|
|
134
|
+
.filter((check) => !check.ok && check.severity === "error")
|
|
135
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
136
|
+
const warnings = checks
|
|
137
|
+
.filter((check) => !check.ok && check.severity === "warning")
|
|
138
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
139
|
+
const stageNames = [
|
|
140
|
+
"build",
|
|
141
|
+
"environment",
|
|
142
|
+
"database",
|
|
143
|
+
"auth",
|
|
144
|
+
"workos",
|
|
145
|
+
"auth metadata",
|
|
146
|
+
"field test",
|
|
147
|
+
"live/runtime",
|
|
148
|
+
"production",
|
|
149
|
+
];
|
|
150
|
+
const stages = stageNames
|
|
151
|
+
.map((stage) => {
|
|
152
|
+
const stageChecks = checks.filter((check) => deployStageForCheck(check.name) === stage);
|
|
153
|
+
const stageBlocking = stageChecks
|
|
154
|
+
.filter((check) => !check.ok && check.severity === "error")
|
|
155
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
156
|
+
const stageWarnings = stageChecks
|
|
157
|
+
.filter((check) => !check.ok && check.severity === "warning")
|
|
158
|
+
.map((check) => `${check.name}: ${check.message}`);
|
|
159
|
+
const passed = stageChecks.filter((check) => check.ok || check.severity === "warning").length;
|
|
160
|
+
const score = stageChecks.length === 0 ? 100 : Math.round((passed / stageChecks.length) * 100);
|
|
161
|
+
return {
|
|
162
|
+
name: stage,
|
|
163
|
+
ok: stageBlocking.length === 0,
|
|
164
|
+
score,
|
|
165
|
+
checks: stageChecks.map((check) => check.name),
|
|
166
|
+
blocking: stageBlocking,
|
|
167
|
+
warnings: stageWarnings,
|
|
168
|
+
};
|
|
169
|
+
})
|
|
170
|
+
.filter((stage) => stage.checks.length > 0);
|
|
171
|
+
const passed = checks.filter((check) => check.ok || check.severity === "warning").length;
|
|
172
|
+
const score = checks.length === 0 ? 100 : Math.round((passed / checks.length) * 100);
|
|
173
|
+
const canPublish = blocking.length === 0;
|
|
174
|
+
return {
|
|
175
|
+
score,
|
|
176
|
+
status: blocking.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "ready",
|
|
177
|
+
blocking,
|
|
178
|
+
warnings,
|
|
179
|
+
stages,
|
|
180
|
+
answers: {
|
|
181
|
+
canPublish,
|
|
182
|
+
ifNotWhatIsMissing: blocking,
|
|
183
|
+
nextCommand: canPublish ? "forge deploy verify --production --url https://app.example.com --json" : nextActions[0] ?? null,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
96
188
|
function hasWorkOSIntegration(workspaceRoot: string): boolean {
|
|
97
189
|
const secrets = new Set(requiredSecretNames(workspaceRoot));
|
|
98
190
|
return secrets.has("WORKOS_API_KEY") ||
|
|
@@ -596,7 +688,7 @@ function databaseReadyCommand(options: DeployCommandOptions): string {
|
|
|
596
688
|
function renderProductionReadme(): string {
|
|
597
689
|
return `# ForgeOS Production Deploy
|
|
598
690
|
|
|
599
|
-
This directory is generated by \`forge deploy package --target docker\`.
|
|
691
|
+
This directory is generated by \`forge deploy init --target docker\` or \`forge deploy package --target docker\`.
|
|
600
692
|
|
|
601
693
|
## 1. Prepare production env
|
|
602
694
|
|
|
@@ -619,13 +711,17 @@ For WorkOS-backed apps, local dry-runs are not enough for production deploy
|
|
|
619
711
|
readiness. Run:
|
|
620
712
|
|
|
621
713
|
\`\`\`bash
|
|
714
|
+
forge workos setup --real --file workos-seed.yml --json
|
|
622
715
|
forge workos prove --real --file workos-seed.yml --json
|
|
623
716
|
\`\`\`
|
|
624
717
|
|
|
625
|
-
This applies or confirms hosted
|
|
626
|
-
writes \`.workos-seed-state.json\`. \`forge deploy
|
|
627
|
-
that state to match the current \`workos-seed.yml\`
|
|
628
|
-
posture as production evidence.
|
|
718
|
+
This applies or confirms hosted redirect/CORS/webhook config and seed data
|
|
719
|
+
through the WorkOS CLI and writes \`.workos-seed-state.json\`. \`forge deploy
|
|
720
|
+
check --production\` requires that state to match the current \`workos-seed.yml\`
|
|
721
|
+
before treating WorkOS posture as production evidence. WorkOS FGA is optional:
|
|
722
|
+
most apps can ship with AuthKit, RBAC permission claims, Forge policies, and
|
|
723
|
+
tenant isolation; use \`forge add auth workos --with-fga\` only when the app needs
|
|
724
|
+
resource-level authorization outside normal role/permission checks.
|
|
629
725
|
|
|
630
726
|
## 2. Prove the app before traffic
|
|
631
727
|
|
|
@@ -633,9 +729,12 @@ posture as production evidence.
|
|
|
633
729
|
forge generate --check --json
|
|
634
730
|
forge check --json
|
|
635
731
|
forge authmd generate --json
|
|
732
|
+
forge env doctor --target production --json
|
|
636
733
|
forge auth prove --scenario multi-tenant --json
|
|
734
|
+
forge workos setup --real --file workos-seed.yml --json
|
|
637
735
|
forge workos prove --real --file workos-seed.yml --json
|
|
638
736
|
forge field-test run --realistic --json
|
|
737
|
+
forge deploy readiness --production --json
|
|
639
738
|
forge deploy check --production --json
|
|
640
739
|
\`\`\`
|
|
641
740
|
|
|
@@ -654,7 +753,7 @@ forge auth prove --prod --token <jwt> --json
|
|
|
654
753
|
forge deploy verify --production --url https://app.example.com --json
|
|
655
754
|
\`\`\`
|
|
656
755
|
|
|
657
|
-
\`forge auth prove --prod\` verifies a real JWT/OIDC token against the configured issuer/audience/JWKS. \`forge deploy verify --production\` probes \`GET /health\`, \`HEAD /auth.md\`, \`GET /auth.md\`, \`HEAD /.well-known/oauth-protected-resource\`, and \`GET /.well-known/oauth-protected-resource\`; it also validates auth metadata content-types and requires the OAuth protected-resource metadata body to be valid JSON.
|
|
756
|
+
\`forge auth prove --prod\` verifies a real JWT/OIDC token against the configured issuer/audience/JWKS. \`forge deploy verify --production\` probes required endpoints \`GET /health\`, \`HEAD /auth.md\`, \`GET /auth.md\`, \`HEAD /.well-known/oauth-protected-resource\`, and \`GET /.well-known/oauth-protected-resource\`; it also validates auth metadata content-types and requires the OAuth protected-resource metadata body to be valid JSON. Optional probes check \`GET /ready\`, \`GET /live/status\`, \`GET /outbox/status\`, and \`HEAD /webhooks/workos\` when the app exposes those operational endpoints.
|
|
658
757
|
`;
|
|
659
758
|
}
|
|
660
759
|
|
|
@@ -669,10 +768,17 @@ function buildPlan(options: DeployCommandOptions): DeployCommandResult {
|
|
|
669
768
|
"forge deploy verify --production --url https://<your-forge-cloud-app> --json",
|
|
670
769
|
]
|
|
671
770
|
: [
|
|
672
|
-
"forge deploy
|
|
771
|
+
"forge deploy init --target docker",
|
|
673
772
|
"cp deploy/.env.production.example deploy/.env.production",
|
|
674
|
-
"forge
|
|
773
|
+
"forge authmd generate --json",
|
|
774
|
+
"forge env doctor --target production --json",
|
|
775
|
+
"forge auth prove --scenario multi-tenant --json",
|
|
776
|
+
"forge workos setup --real --file workos-seed.yml --json",
|
|
777
|
+
"forge workos prove --real --file workos-seed.yml --json",
|
|
675
778
|
FIELD_TEST_PRODUCTION_COMMAND,
|
|
779
|
+
"forge deploy readiness --production --json",
|
|
780
|
+
"forge deploy check --production --json",
|
|
781
|
+
"forge deploy package --target docker",
|
|
676
782
|
"docker compose -f deploy/docker-compose.yml up --build",
|
|
677
783
|
"forge deploy verify --production --url https://app.example.com --json",
|
|
678
784
|
];
|
|
@@ -699,10 +805,12 @@ function buildPlan(options: DeployCommandOptions): DeployCommandResult {
|
|
|
699
805
|
"field-test report exists with runtime/auth/UI probes",
|
|
700
806
|
"runtime /health responds",
|
|
701
807
|
"tenant and policy proof is run before public traffic",
|
|
808
|
+
"readiness score has no blocking items",
|
|
702
809
|
],
|
|
703
810
|
notes: [
|
|
704
811
|
"dev-headers auth is local-only and must not be enabled for public runtime.",
|
|
705
812
|
"Use forge workos doctor/seed when WorkOS is configured.",
|
|
813
|
+
"WorkOS FGA is optional; ordinary WorkOS apps can be production-ready with AuthKit/RBAC permission claims, Forge policies, and tenant isolation.",
|
|
706
814
|
"Use forge test authz or HTTP probes to prove cross-tenant denial.",
|
|
707
815
|
],
|
|
708
816
|
},
|
|
@@ -971,6 +1079,10 @@ async function buildChecks(options: DeployCommandOptions): Promise<DeployCommand
|
|
|
971
1079
|
const failureActions = unique(checks.flatMap((check) =>
|
|
972
1080
|
!check.ok && check.command ? expandDeployFailureCommand(check.command) : []
|
|
973
1081
|
));
|
|
1082
|
+
const nextActions = errorFree
|
|
1083
|
+
? ["forge deploy verify --production --url https://app.example.com --json"]
|
|
1084
|
+
: failureActions;
|
|
1085
|
+
const readiness = summarizeDeployReadiness(checks, nextActions);
|
|
974
1086
|
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
975
1087
|
schemaVersion: "0.1.0",
|
|
976
1088
|
ok: errorFree,
|
|
@@ -979,9 +1091,10 @@ async function buildChecks(options: DeployCommandOptions): Promise<DeployCommand
|
|
|
979
1091
|
target: options.target,
|
|
980
1092
|
production: options.production,
|
|
981
1093
|
checks,
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1094
|
+
blocking: readiness.blocking,
|
|
1095
|
+
warnings: readiness.warnings,
|
|
1096
|
+
readiness,
|
|
1097
|
+
nextActions,
|
|
985
1098
|
exitCode: errorFree ? 0 : 1,
|
|
986
1099
|
});
|
|
987
1100
|
}
|
|
@@ -1020,6 +1133,7 @@ function renderDocker(options: DeployCommandOptions): DeployCommandResult {
|
|
|
1020
1133
|
files: files.map(([file]) => file),
|
|
1021
1134
|
nextActions: [
|
|
1022
1135
|
"cp deploy/.env.production.example deploy/.env.production",
|
|
1136
|
+
"forge deploy readiness --production --json",
|
|
1023
1137
|
"forge deploy check --production --json",
|
|
1024
1138
|
"docker compose -f deploy/docker-compose.yml up --build",
|
|
1025
1139
|
],
|
|
@@ -1087,22 +1201,32 @@ async function verifyUrl(options: DeployCommandOptions): Promise<DeployCommandRe
|
|
|
1087
1201
|
exitCode: 1,
|
|
1088
1202
|
};
|
|
1089
1203
|
}
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1204
|
+
const probeSpecs: Array<{ method: "GET" | "HEAD"; path: string; expectation?: DeployProbeExpectation }> = [
|
|
1205
|
+
{ method: "GET", path: "/health", expectation: { required: true } },
|
|
1206
|
+
{ method: "HEAD", path: "/auth.md", expectation: { contentTypeIncludes: "text/markdown", required: true } },
|
|
1207
|
+
{ method: "GET", path: "/auth.md", expectation: { contentTypeIncludes: "text/markdown", required: true } },
|
|
1208
|
+
{ method: "HEAD", path: "/.well-known/oauth-protected-resource", expectation: { contentTypeIncludes: "application/json", required: true } },
|
|
1209
|
+
{ method: "GET", path: "/.well-known/oauth-protected-resource", expectation: { contentTypeIncludes: "application/json", json: true, required: true } },
|
|
1210
|
+
{ method: "GET", path: "/ready", expectation: { contentTypeIncludes: "application/json" } },
|
|
1211
|
+
{ method: "GET", path: "/live/status", expectation: { contentTypeIncludes: "application/json" } },
|
|
1212
|
+
{ method: "GET", path: "/outbox/status", expectation: { contentTypeIncludes: "application/json" } },
|
|
1213
|
+
{ method: "HEAD", path: "/webhooks/workos" },
|
|
1214
|
+
];
|
|
1215
|
+
const probes = await Promise.all(probeSpecs.map((item) =>
|
|
1216
|
+
probe(item.method, `${base}${item.path}`, item.expectation ?? {})
|
|
1217
|
+
));
|
|
1097
1218
|
const checks = probes.map((item): DeployCheck => ({
|
|
1098
1219
|
name: `${item.method} ${item.url.replace(base, "") || "/"}`,
|
|
1099
1220
|
ok: item.ok,
|
|
1100
|
-
severity:
|
|
1221
|
+
severity: probeSpecs.find((spec) => `${base}${spec.path}` === item.url && spec.method === item.method)?.expectation?.required || item.url.endsWith("/health")
|
|
1222
|
+
? "error"
|
|
1223
|
+
: "warning",
|
|
1101
1224
|
message: item.ok
|
|
1102
1225
|
? `HTTP ${item.status}${item.contentType ? ` ${item.contentType}` : ""}`
|
|
1103
1226
|
: item.error ?? `HTTP ${item.status ?? "failed"}`,
|
|
1104
1227
|
}));
|
|
1105
1228
|
const ok = checks.every((check) => check.ok || check.severity === "warning");
|
|
1229
|
+
const readiness = summarizeDeployReadiness(checks, ok ? [] : ["check runtime logs", "forge deploy check --production --json"]);
|
|
1106
1230
|
return {
|
|
1107
1231
|
schemaVersion: "0.1.0",
|
|
1108
1232
|
ok,
|
|
@@ -1111,16 +1235,33 @@ async function verifyUrl(options: DeployCommandOptions): Promise<DeployCommandRe
|
|
|
1111
1235
|
target: options.target,
|
|
1112
1236
|
production: options.production,
|
|
1113
1237
|
checks,
|
|
1238
|
+
blocking: readiness.blocking,
|
|
1239
|
+
warnings: readiness.warnings,
|
|
1114
1240
|
probes,
|
|
1115
|
-
|
|
1241
|
+
readiness,
|
|
1242
|
+
nextActions: ok
|
|
1243
|
+
? [
|
|
1244
|
+
"forge auth prove --prod --token <jwt> --json",
|
|
1245
|
+
"forge auth prove --scenario multi-tenant --json",
|
|
1246
|
+
"forge handoff --json",
|
|
1247
|
+
]
|
|
1248
|
+
: ["check runtime logs", "forge deploy check --production --json"],
|
|
1116
1249
|
exitCode: ok ? 0 : 1,
|
|
1117
1250
|
};
|
|
1118
1251
|
}
|
|
1119
1252
|
|
|
1120
1253
|
export async function runDeployCommand(options: DeployCommandOptions): Promise<DeployCommandResult> {
|
|
1121
1254
|
if (options.subcommand === "plan") return buildPlan(options);
|
|
1255
|
+
if (options.subcommand === "readiness") {
|
|
1256
|
+
const result = await buildChecks({ ...options, subcommand: "check", production: true });
|
|
1257
|
+
return {
|
|
1258
|
+
...result,
|
|
1259
|
+
action: "readiness",
|
|
1260
|
+
nextActions: result.readiness?.answers.nextCommand ? [result.readiness.answers.nextCommand] : result.nextActions,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1122
1263
|
if (options.subcommand === "check") return buildChecks(options);
|
|
1123
|
-
if (options.subcommand === "render" || options.subcommand === "package") return renderDocker(options);
|
|
1264
|
+
if (options.subcommand === "init" || options.subcommand === "render" || options.subcommand === "package") return renderDocker(options);
|
|
1124
1265
|
return verifyUrl(options);
|
|
1125
1266
|
}
|
|
1126
1267
|
|
|
@@ -1132,7 +1273,13 @@ export function formatDeployHuman(result: DeployCommandResult): string {
|
|
|
1132
1273
|
const lines = [
|
|
1133
1274
|
`deploy ${result.action} ${result.ok ? "ok" : "failed"}`,
|
|
1134
1275
|
`target: ${result.target}`,
|
|
1276
|
+
...(result.readiness ? [
|
|
1277
|
+
`readiness: ${result.readiness.status} (${result.readiness.score}/100)`,
|
|
1278
|
+
`can publish: ${result.readiness.answers.canPublish ? "yes" : "no"}`,
|
|
1279
|
+
] : []),
|
|
1135
1280
|
...result.checks.map((check) => `${check.ok ? "ok" : check.severity === "warning" ? "warn" : "fail"} ${check.name}: ${check.message}`),
|
|
1281
|
+
...(result.blocking?.length ? ["", "Blocking:", ...result.blocking.map((item) => ` ${item}`)] : []),
|
|
1282
|
+
...(result.warnings?.length ? ["", "Warnings:", ...result.warnings.map((item) => ` ${item}`)] : []),
|
|
1136
1283
|
...(result.files?.length ? ["", "Files:", ...result.files.map((file) => ` ${file}`)] : []),
|
|
1137
1284
|
...(result.plan ? ["", result.plan.summary, "", "Commands:", ...result.plan.commands.map((command) => ` ${command}`)] : []),
|
|
1138
1285
|
...(result.nextActions.length ? ["", "Next:", ...result.nextActions.map((action) => ` ${action}`)] : []),
|
package/src/forge/cli/main.ts
CHANGED
|
@@ -20,6 +20,9 @@ function formatHelp(): string {
|
|
|
20
20
|
" forge field-test create vendor-access --auth workos --install --git --json Create a real field-test app",
|
|
21
21
|
" forge field-test run --realistic --templates vendor-access --package-managers npm --json",
|
|
22
22
|
" forge field-test report --json Summarize the machine-readable field-test report",
|
|
23
|
+
" forge deploy init --target docker --json Generate Docker production files, env template, and deploy README",
|
|
24
|
+
" forge deploy readiness --production --json Answer whether the app can publish, what blocks it, and the next command",
|
|
25
|
+
" forge env doctor --target production --json Show production env sources, missing keys, auth mode, database, and provider",
|
|
23
26
|
" forge seed status --json Discover app seed commands from the generated runtime graph",
|
|
24
27
|
" forge seed dev --json Run the app seed command against the local Forge dev runtime",
|
|
25
28
|
" forge seed dev --all-tenants --json Seed every discovered local tenant/persona profile",
|
|
@@ -51,7 +54,7 @@ function formatHelp(): string {
|
|
|
51
54
|
" forge deploy plan --target docker --json Explain production deploy gates and commands",
|
|
52
55
|
" forge deploy check --production --json Gate auth, DB, metadata, generated artifacts, and liveQuery readiness",
|
|
53
56
|
" forge deploy package --target docker Write Docker production deploy files under deploy/",
|
|
54
|
-
" forge deploy verify --production --url https://app.example.com --json Probe
|
|
57
|
+
" forge deploy verify --production --url https://app.example.com --json Probe health, public auth metadata, and optional runtime endpoints",
|
|
55
58
|
" forge release check --allow-missing-local-release --json Gate release readiness without failing on unprepared local artifacts",
|
|
56
59
|
" forge self-host check --prepared-only --json Report compose readiness without creating deploy files",
|
|
57
60
|
" forge delta status --verbose --json Include Delta schema, lock, and aggregate count details",
|
package/src/forge/cli/parse.ts
CHANGED
|
@@ -418,6 +418,7 @@ export type ForgeCommand =
|
|
|
418
418
|
subcommand: EnvSubcommand;
|
|
419
419
|
json: boolean;
|
|
420
420
|
redacted: boolean;
|
|
421
|
+
target?: "local" | "staging" | "production";
|
|
421
422
|
workspaceRoot: string;
|
|
422
423
|
}
|
|
423
424
|
| {
|
|
@@ -602,7 +603,7 @@ const BASELINE_SUBCOMMANDS: BaselineSubcommand[] = ["create", "status"];
|
|
|
602
603
|
const AUTHMD_SUBCOMMANDS: AuthMdSubcommand[] = ["generate", "check"];
|
|
603
604
|
const WORKOS_SUBCOMMANDS: WorkOSSubcommand[] = ["install", "doctor", "seed", "setup", "prove", "fga"];
|
|
604
605
|
const WORKOS_FGA_ACTIONS: WorkOSFgaAction[] = ["plan", "sync", "prove", "doctor"];
|
|
605
|
-
const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "check", "render", "package", "verify"];
|
|
606
|
+
const DEPLOY_SUBCOMMANDS: DeploySubcommand[] = ["plan", "init", "check", "readiness", "render", "package", "verify"];
|
|
606
607
|
const FIELD_TEST_SUBCOMMANDS: FieldTestSubcommand[] = ["create", "run", "report"];
|
|
607
608
|
const SECURITY_SUBCOMMANDS: SecuritySubcommand[] = ["prove"];
|
|
608
609
|
const RLS_SUBCOMMANDS: RlsSubcommand[] = ["generate", "check", "apply", "test", "mutate-test"];
|
|
@@ -1484,10 +1485,10 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
1484
1485
|
case "deploy": {
|
|
1485
1486
|
const subcommand = rest[0] as DeploySubcommand | undefined;
|
|
1486
1487
|
if (!subcommand || !DEPLOY_SUBCOMMANDS.includes(subcommand)) {
|
|
1487
|
-
errors.push("forge deploy requires subcommand: plan, check, render, package, or verify");
|
|
1488
|
+
errors.push("forge deploy requires subcommand: plan, init, check, readiness, render, package, or verify");
|
|
1488
1489
|
return { command: null, workspaceRoot, errors };
|
|
1489
1490
|
}
|
|
1490
|
-
const targetRaw = parseOptionValue(argv, "--target") ?? (subcommand === "render" || subcommand === "package" ? rest[1] : undefined) ?? "docker";
|
|
1491
|
+
const targetRaw = parseOptionValue(argv, "--target") ?? (subcommand === "init" || subcommand === "render" || subcommand === "package" ? rest[1] : undefined) ?? "docker";
|
|
1491
1492
|
if (targetRaw !== "docker" && targetRaw !== "forge-cloud") {
|
|
1492
1493
|
errors.push("forge deploy --target must be docker or forge-cloud");
|
|
1493
1494
|
}
|
|
@@ -2974,10 +2975,14 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
2974
2975
|
}
|
|
2975
2976
|
case "env": {
|
|
2976
2977
|
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
|
|
2978
|
+
if (!subcommand || !["list", "check", "print", "doctor"].includes(subcommand)) {
|
|
2979
|
+
errors.push("forge env requires subcommand: list, check, print, or doctor");
|
|
2979
2980
|
return { command: null, workspaceRoot, errors };
|
|
2980
2981
|
}
|
|
2982
|
+
const targetRaw = parseOptionValue(argv, "--target") ?? "local";
|
|
2983
|
+
if (!["local", "staging", "production"].includes(targetRaw)) {
|
|
2984
|
+
errors.push("forge env --target must be local, staging, or production");
|
|
2985
|
+
}
|
|
2981
2986
|
|
|
2982
2987
|
return {
|
|
2983
2988
|
command: {
|
|
@@ -2985,6 +2990,7 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
2985
2990
|
subcommand,
|
|
2986
2991
|
json: parseFlag(argv, "--json"),
|
|
2987
2992
|
redacted: parseFlag(argv, "--redacted"),
|
|
2993
|
+
target: targetRaw as "local" | "staging" | "production",
|
|
2988
2994
|
workspaceRoot,
|
|
2989
2995
|
},
|
|
2990
2996
|
workspaceRoot,
|
package/src/forge/cli/secrets.ts
CHANGED
|
@@ -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 {
|
package/src/forge/version.ts
CHANGED