forgeos 0.1.0-alpha.37 → 0.1.0-alpha.39
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 +15 -0
- package/docs/changelog.md +37 -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/auth.ts +171 -13
- package/src/forge/cli/commands.ts +73 -5
- package/src/forge/cli/deploy.ts +887 -0
- package/src/forge/cli/docs.ts +1 -1
- package/src/forge/cli/doctor.ts +216 -0
- package/src/forge/cli/field-test.ts +328 -0
- package/src/forge/cli/handoff.ts +13 -5
- package/src/forge/cli/main.ts +11 -2
- package/src/forge/cli/new.ts +5 -0
- package/src/forge/cli/output.ts +1 -1
- package/src/forge/cli/parse.ts +142 -4
- package/src/forge/cli/workos.ts +118 -23
- package/src/forge/compiler/integration/plan.ts +13 -1
- package/src/forge/compiler/integration/templates/workos.ts +2 -2
- package/src/forge/version.ts +1 -1
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import { nodeFileSystem } from "../compiler/fs/index.ts";
|
|
2
|
+
import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
|
|
3
|
+
import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
|
|
4
|
+
import { run as runGenerate } from "../compiler/orchestrator/run.ts";
|
|
5
|
+
import { detectPackageManager, getLockfileCandidates } from "../compiler/package-manager/detect.ts";
|
|
6
|
+
import type { FrontendGraph } from "../compiler/types/frontend-graph.ts";
|
|
7
|
+
import type { PackageManager } from "../compiler/types/runtime.ts";
|
|
8
|
+
import { loadAuthConfigFromEnv } from "../runtime/auth/config.ts";
|
|
9
|
+
import { loadSecretRegistry } from "../runtime/secrets/check.ts";
|
|
10
|
+
import { normalizeForgeCliCommandsInValue } from "../workspace/forge-cli.ts";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { runAuthCommand } from "./auth.ts";
|
|
13
|
+
import { runAuthMdCommand } from "./authmd.ts";
|
|
14
|
+
import { runWorkOSCommand } from "./workos.ts";
|
|
15
|
+
|
|
16
|
+
export type DeploySubcommand = "plan" | "check" | "render" | "verify";
|
|
17
|
+
export type DeployTarget = "docker" | "forge-cloud";
|
|
18
|
+
|
|
19
|
+
export interface DeployCommandOptions {
|
|
20
|
+
subcommand: DeploySubcommand;
|
|
21
|
+
workspaceRoot: string;
|
|
22
|
+
json: boolean;
|
|
23
|
+
target: DeployTarget;
|
|
24
|
+
production: boolean;
|
|
25
|
+
url?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeployCheck {
|
|
29
|
+
name: string;
|
|
30
|
+
ok: boolean;
|
|
31
|
+
severity: "error" | "warning";
|
|
32
|
+
message: string;
|
|
33
|
+
command?: string;
|
|
34
|
+
details?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DeployCommandResult {
|
|
38
|
+
schemaVersion: "0.1.0";
|
|
39
|
+
ok: boolean;
|
|
40
|
+
kind: "deploy";
|
|
41
|
+
action: DeploySubcommand;
|
|
42
|
+
target: DeployTarget;
|
|
43
|
+
production: boolean;
|
|
44
|
+
checks: DeployCheck[];
|
|
45
|
+
files?: string[];
|
|
46
|
+
plan?: {
|
|
47
|
+
summary: string;
|
|
48
|
+
commands: string[];
|
|
49
|
+
gates: string[];
|
|
50
|
+
notes: string[];
|
|
51
|
+
};
|
|
52
|
+
probes?: Array<{ method: string; url: string; ok: boolean; status?: number; contentType?: string; error?: string; jsonValid?: boolean }>;
|
|
53
|
+
nextActions: string[];
|
|
54
|
+
exitCode: 0 | 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface DeployProbeExpectation {
|
|
58
|
+
contentTypeIncludes?: string;
|
|
59
|
+
json?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readGeneratedJson<T>(workspaceRoot: string, relative: string): T | null {
|
|
63
|
+
const absolute = join(workspaceRoot, relative);
|
|
64
|
+
if (!nodeFileSystem.exists(absolute)) return null;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(stripDeterministicHeader(nodeFileSystem.readText(absolute) ?? "")) as T;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function packageJson(workspaceRoot: string): Record<string, unknown> {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(nodeFileSystem.readText(join(workspaceRoot, "package.json")) ?? "{}") as Record<string, unknown>;
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasScript(workspaceRoot: string, name: string): boolean {
|
|
81
|
+
const scripts = packageJson(workspaceRoot).scripts;
|
|
82
|
+
return Boolean(scripts && typeof scripts === "object" && name in scripts);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requiredSecretNames(workspaceRoot: string): string[] {
|
|
86
|
+
return (loadSecretRegistry(workspaceRoot)?.secrets ?? [])
|
|
87
|
+
.map((secret) => secret.name)
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unique(values: string[]): string[] {
|
|
93
|
+
return [...new Set(values)];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasWorkOSIntegration(workspaceRoot: string): boolean {
|
|
97
|
+
const secrets = new Set(requiredSecretNames(workspaceRoot));
|
|
98
|
+
return secrets.has("WORKOS_API_KEY") ||
|
|
99
|
+
secrets.has("WORKOS_CLIENT_ID") ||
|
|
100
|
+
nodeFileSystem.exists(join(workspaceRoot, `${GENERATED_DIR}/integrations/workos`)) ||
|
|
101
|
+
nodeFileSystem.exists(join(workspaceRoot, "src/policies.workos.ts"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readJsonFile<T>(workspaceRoot: string, relative: string): T | null {
|
|
105
|
+
const absolute = join(workspaceRoot, relative);
|
|
106
|
+
if (!nodeFileSystem.exists(absolute)) return null;
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(nodeFileSystem.readText(absolute) ?? "null") as T;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function fieldTestReportCandidates(workspaceRoot: string): Array<{ path: string; data: Record<string, unknown> }> {
|
|
115
|
+
const paths = [".forge/field-test-report.json", "field-reports/full-alpha.json"];
|
|
116
|
+
return paths.flatMap((path) => {
|
|
117
|
+
const data = readJsonFile<Record<string, unknown>>(workspaceRoot, path);
|
|
118
|
+
return data ? [{ path, data }] : [];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function summarizeFieldTestReport(data: Record<string, unknown>) {
|
|
123
|
+
const results = Array.isArray(data.results) ? data.results as Array<Record<string, unknown>> : [];
|
|
124
|
+
const failed = results.filter((result) => result.ok === false && result.skipped !== true);
|
|
125
|
+
const skipped = results.filter((result) => result.skipped === true);
|
|
126
|
+
const runtimeSteps = results.flatMap((result) => {
|
|
127
|
+
const runtime = result.runtime && typeof result.runtime === "object"
|
|
128
|
+
? result.runtime as Record<string, unknown>
|
|
129
|
+
: {};
|
|
130
|
+
return Array.isArray(runtime.steps) ? runtime.steps as Array<Record<string, unknown>> : [];
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
ok: data.ok === true,
|
|
134
|
+
cases: results.length,
|
|
135
|
+
passed: results.filter((result) => result.ok === true && result.skipped !== true).length,
|
|
136
|
+
failed: failed.length,
|
|
137
|
+
skipped: skipped.length,
|
|
138
|
+
runtimeProbes: data.runtimeProbes === true,
|
|
139
|
+
authProbes: data.authProbes === true,
|
|
140
|
+
runtimeProbeSteps: runtimeSteps.length,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function shellCommand(command: string): string {
|
|
145
|
+
return `["sh", "-lc", ${JSON.stringify(command)}]`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function runPackageScript(packageManager: PackageManager, script: string): string {
|
|
149
|
+
switch (packageManager) {
|
|
150
|
+
case "npm":
|
|
151
|
+
return `npm run ${script}`;
|
|
152
|
+
case "pnpm":
|
|
153
|
+
return `pnpm run ${script}`;
|
|
154
|
+
case "yarn":
|
|
155
|
+
return `yarn run ${script}`;
|
|
156
|
+
case "bun":
|
|
157
|
+
return `bun run ${script}`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function runForgeScript(packageManager: PackageManager, args: string): string {
|
|
162
|
+
switch (packageManager) {
|
|
163
|
+
case "npm":
|
|
164
|
+
return `npm run forge -- ${args}`;
|
|
165
|
+
case "pnpm":
|
|
166
|
+
return `pnpm run forge -- ${args}`;
|
|
167
|
+
case "yarn":
|
|
168
|
+
return `yarn run forge ${args}`;
|
|
169
|
+
case "bun":
|
|
170
|
+
return `bun run forge -- ${args}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function packageManagerSpec(workspaceRoot: string, packageManager: PackageManager): string {
|
|
175
|
+
try {
|
|
176
|
+
const pkg = JSON.parse(nodeFileSystem.readText(join(workspaceRoot, "package.json")) ?? "{}") as { packageManager?: string };
|
|
177
|
+
if (pkg.packageManager?.startsWith(`${packageManager}@`)) return pkg.packageManager;
|
|
178
|
+
} catch {
|
|
179
|
+
// fall through to package manager name
|
|
180
|
+
}
|
|
181
|
+
return packageManager;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function dockerPackageCopyLine(workspaceRoot: string, packageManager: PackageManager): string {
|
|
185
|
+
const files = [
|
|
186
|
+
"package.json",
|
|
187
|
+
...activeLockfiles(workspaceRoot, packageManager),
|
|
188
|
+
];
|
|
189
|
+
return `COPY ${files.join(" ")} ./`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function activeLockfiles(workspaceRoot: string, packageManager = detectPackageManager(workspaceRoot)): string[] {
|
|
193
|
+
return getLockfileCandidates(packageManager).filter((file) => nodeFileSystem.exists(join(workspaceRoot, file)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function packageInstallCommand(packageManager: PackageManager): string {
|
|
197
|
+
switch (packageManager) {
|
|
198
|
+
case "npm":
|
|
199
|
+
return "npm install";
|
|
200
|
+
case "pnpm":
|
|
201
|
+
return "pnpm install";
|
|
202
|
+
case "yarn":
|
|
203
|
+
return "yarn install";
|
|
204
|
+
case "bun":
|
|
205
|
+
return "bun install";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function dockerPackageSetup(workspaceRoot: string, packageManager: PackageManager): string[] {
|
|
210
|
+
if (packageManager === "npm") return [];
|
|
211
|
+
if (packageManager === "bun") {
|
|
212
|
+
return [`RUN npm install -g ${packageManagerSpec(workspaceRoot, packageManager)}`];
|
|
213
|
+
}
|
|
214
|
+
return ["RUN corepack enable"];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function dockerInstallCommand(workspaceRoot: string, packageManager: PackageManager): string {
|
|
218
|
+
const hasLockfile = activeLockfiles(workspaceRoot, packageManager).length > 0;
|
|
219
|
+
switch (packageManager) {
|
|
220
|
+
case "npm":
|
|
221
|
+
return hasLockfile ? "RUN npm ci" : "RUN npm install";
|
|
222
|
+
case "pnpm":
|
|
223
|
+
return hasLockfile ? "RUN pnpm install --frozen-lockfile" : "RUN pnpm install";
|
|
224
|
+
case "yarn":
|
|
225
|
+
return hasLockfile ? "RUN yarn install --immutable || yarn install --frozen-lockfile" : "RUN yarn install";
|
|
226
|
+
case "bun":
|
|
227
|
+
return hasLockfile ? "RUN bun install --frozen-lockfile" : "RUN bun install";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function dockerRunIfScriptPresent(packageManager: PackageManager, script: string): string {
|
|
232
|
+
return `RUN if node -e "process.exit(require('./package.json').scripts?.[${JSON.stringify(script)}] ? 0 : 1)"; then ${runPackageScript(packageManager, script)}; fi`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderDockerCompose(workspaceRoot: string): string {
|
|
236
|
+
const packageManager = detectPackageManager(workspaceRoot);
|
|
237
|
+
return `services:
|
|
238
|
+
postgres:
|
|
239
|
+
image: postgres:16
|
|
240
|
+
environment:
|
|
241
|
+
POSTGRES_USER: forge
|
|
242
|
+
POSTGRES_PASSWORD: forge
|
|
243
|
+
POSTGRES_DB: forge_app
|
|
244
|
+
volumes:
|
|
245
|
+
- postgres_data:/var/lib/postgresql/data
|
|
246
|
+
healthcheck:
|
|
247
|
+
test: ["CMD-SHELL", "pg_isready -U forge -d forge_app"]
|
|
248
|
+
interval: 5s
|
|
249
|
+
timeout: 5s
|
|
250
|
+
retries: 10
|
|
251
|
+
|
|
252
|
+
forge-migrate:
|
|
253
|
+
build:
|
|
254
|
+
context: ..
|
|
255
|
+
dockerfile: deploy/Dockerfile.runtime
|
|
256
|
+
command: ${shellCommand(runForgeScript(packageManager, "db migrate --db postgres"))}
|
|
257
|
+
env_file:
|
|
258
|
+
- .env.production
|
|
259
|
+
depends_on:
|
|
260
|
+
postgres:
|
|
261
|
+
condition: service_healthy
|
|
262
|
+
|
|
263
|
+
forge-runtime:
|
|
264
|
+
build:
|
|
265
|
+
context: ..
|
|
266
|
+
dockerfile: deploy/Dockerfile.runtime
|
|
267
|
+
command: ${shellCommand(runForgeScript(packageManager, "serve --host 0.0.0.0 --port 3765"))}
|
|
268
|
+
env_file:
|
|
269
|
+
- .env.production
|
|
270
|
+
environment:
|
|
271
|
+
FORGE_ENV: production
|
|
272
|
+
FORGE_DEPLOY_ENV: production
|
|
273
|
+
FORGE_LIVE_TRANSPORT: sse
|
|
274
|
+
FORGE_LIVE_INVALIDATION: polling,postgres-notify
|
|
275
|
+
depends_on:
|
|
276
|
+
postgres:
|
|
277
|
+
condition: service_healthy
|
|
278
|
+
forge-migrate:
|
|
279
|
+
condition: service_completed_successfully
|
|
280
|
+
ports:
|
|
281
|
+
- "3765:3765"
|
|
282
|
+
|
|
283
|
+
forge-worker:
|
|
284
|
+
build:
|
|
285
|
+
context: ..
|
|
286
|
+
dockerfile: deploy/Dockerfile.runtime
|
|
287
|
+
command: ${shellCommand(runForgeScript(packageManager, "worker --db postgres"))}
|
|
288
|
+
env_file:
|
|
289
|
+
- .env.production
|
|
290
|
+
environment:
|
|
291
|
+
FORGE_ENV: production
|
|
292
|
+
FORGE_DEPLOY_ENV: production
|
|
293
|
+
depends_on:
|
|
294
|
+
postgres:
|
|
295
|
+
condition: service_healthy
|
|
296
|
+
forge-migrate:
|
|
297
|
+
condition: service_completed_successfully
|
|
298
|
+
|
|
299
|
+
volumes:
|
|
300
|
+
postgres_data:
|
|
301
|
+
`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderRuntimeDockerfile(workspaceRoot: string): string {
|
|
305
|
+
const packageManager = detectPackageManager(workspaceRoot);
|
|
306
|
+
return `FROM node:22-slim AS deps
|
|
307
|
+
WORKDIR /app
|
|
308
|
+
${dockerPackageCopyLine(workspaceRoot, packageManager)}
|
|
309
|
+
${[...dockerPackageSetup(workspaceRoot, packageManager), dockerInstallCommand(workspaceRoot, packageManager)].join("\n")}
|
|
310
|
+
|
|
311
|
+
FROM deps AS build
|
|
312
|
+
COPY . .
|
|
313
|
+
RUN ${runForgeScript(packageManager, "generate")}
|
|
314
|
+
${dockerRunIfScriptPresent(packageManager, "typecheck")}
|
|
315
|
+
|
|
316
|
+
FROM node:22-slim AS runtime
|
|
317
|
+
WORKDIR /app
|
|
318
|
+
ENV NODE_ENV=production
|
|
319
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
320
|
+
COPY --from=build /app ./
|
|
321
|
+
EXPOSE 3765
|
|
322
|
+
CMD ${shellCommand(runForgeScript(packageManager, "serve --host 0.0.0.0 --port 3765"))}
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function renderEnvExample(workspaceRoot: string): string {
|
|
327
|
+
const secrets = requiredSecretNames(workspaceRoot);
|
|
328
|
+
const base = [
|
|
329
|
+
"DATABASE_URL=postgres://forge:forge@postgres:5432/forge_app",
|
|
330
|
+
"FORGE_ENV=production",
|
|
331
|
+
"FORGE_DEPLOY_ENV=production",
|
|
332
|
+
"FORGE_PORT=3765",
|
|
333
|
+
"FORGE_AUTH_MODE=oidc",
|
|
334
|
+
"FORGE_AUTH_ISSUER=",
|
|
335
|
+
"FORGE_AUTH_AUDIENCE=",
|
|
336
|
+
"FORGE_AUTH_JWKS_URI=",
|
|
337
|
+
"FORGE_AUTH_ALGORITHMS=RS256",
|
|
338
|
+
"FORGE_LIVE_TRANSPORT=sse",
|
|
339
|
+
"FORGE_LIVE_INVALIDATION=polling,postgres-notify",
|
|
340
|
+
"FORGE_LIVE_POLL_INTERVAL_MS=1000",
|
|
341
|
+
"FORGE_LIVE_HEARTBEAT_MS=15000",
|
|
342
|
+
"FORGE_CORS_ORIGINS=https://app.example.com",
|
|
343
|
+
];
|
|
344
|
+
const present = new Set(base.map((line) => line.split("=")[0]));
|
|
345
|
+
for (const secret of secrets) {
|
|
346
|
+
if (!present.has(secret)) base.push(`${secret}=`);
|
|
347
|
+
}
|
|
348
|
+
return `${base.join("\n")}\n`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function hasProductionEnvFile(workspaceRoot: string): boolean {
|
|
352
|
+
return nodeFileSystem.exists(join(workspaceRoot, "deploy/.env.production"));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseEnvValue(value: string): string {
|
|
356
|
+
const trimmed = value.trim();
|
|
357
|
+
if (
|
|
358
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
359
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
360
|
+
) {
|
|
361
|
+
return trimmed.slice(1, -1);
|
|
362
|
+
}
|
|
363
|
+
return trimmed;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readDeployEnvFile(workspaceRoot: string): Record<string, string> {
|
|
367
|
+
const absolute = join(workspaceRoot, "deploy/.env.production");
|
|
368
|
+
if (!nodeFileSystem.exists(absolute)) return {};
|
|
369
|
+
const text = nodeFileSystem.readText(absolute) ?? "";
|
|
370
|
+
const values: Record<string, string> = {};
|
|
371
|
+
for (const line of text.split(/\r?\n/)) {
|
|
372
|
+
const trimmed = line.trim();
|
|
373
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
374
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
375
|
+
if (!match) continue;
|
|
376
|
+
values[match[1]!] = parseEnvValue(match[2] ?? "");
|
|
377
|
+
}
|
|
378
|
+
return values;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function deployEnvValue(workspaceRoot: string, name: string): string | undefined {
|
|
382
|
+
return process.env[name] || readDeployEnvFile(workspaceRoot)[name];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function withDeployEnv<T>(workspaceRoot: string, fn: () => Promise<T>): Promise<T> {
|
|
386
|
+
const deployEnv = readDeployEnvFile(workspaceRoot);
|
|
387
|
+
const previous = new Map<string, string | undefined>();
|
|
388
|
+
for (const [name, value] of Object.entries(deployEnv)) {
|
|
389
|
+
if (process.env[name]) continue;
|
|
390
|
+
previous.set(name, process.env[name]);
|
|
391
|
+
process.env[name] = value;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
return await fn();
|
|
395
|
+
} finally {
|
|
396
|
+
for (const [name, value] of previous) {
|
|
397
|
+
if (value === undefined) {
|
|
398
|
+
delete process.env[name];
|
|
399
|
+
} else {
|
|
400
|
+
process.env[name] = value;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function hasRenderedProductionEnvExample(workspaceRoot: string): boolean {
|
|
407
|
+
return nodeFileSystem.exists(join(workspaceRoot, "deploy/.env.production.example"));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function databaseReady(options: DeployCommandOptions): boolean {
|
|
411
|
+
if (deployEnvValue(options.workspaceRoot, "DATABASE_URL")) return true;
|
|
412
|
+
if (!options.production) return hasRenderedProductionEnvExample(options.workspaceRoot);
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function databaseReadyMessage(options: DeployCommandOptions): string {
|
|
417
|
+
if (process.env.DATABASE_URL) return "DATABASE_URL is set in the current environment";
|
|
418
|
+
if (readDeployEnvFile(options.workspaceRoot).DATABASE_URL) return "DATABASE_URL is set in deploy/.env.production";
|
|
419
|
+
if (hasProductionEnvFile(options.workspaceRoot)) return "deploy/.env.production is present but does not set DATABASE_URL";
|
|
420
|
+
if (hasRenderedProductionEnvExample(options.workspaceRoot)) {
|
|
421
|
+
return options.production
|
|
422
|
+
? "deploy/.env.production.example is only a template; copy it to deploy/.env.production with DATABASE_URL or set DATABASE_URL"
|
|
423
|
+
: "deploy/.env.production.example is present";
|
|
424
|
+
}
|
|
425
|
+
return options.production
|
|
426
|
+
? "production deploy requires DATABASE_URL or deploy/.env.production"
|
|
427
|
+
: "deploy readiness requires DATABASE_URL or rendered deploy/.env.production.example";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function databaseReadyCommand(options: DeployCommandOptions): string {
|
|
431
|
+
if (hasRenderedProductionEnvExample(options.workspaceRoot)) {
|
|
432
|
+
return "cp deploy/.env.production.example deploy/.env.production";
|
|
433
|
+
}
|
|
434
|
+
return "forge deploy render docker";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function renderProductionReadme(): string {
|
|
438
|
+
return `# ForgeOS Production Deploy
|
|
439
|
+
|
|
440
|
+
This directory is generated by \`forge deploy render docker\`.
|
|
441
|
+
|
|
442
|
+
## 1. Prepare production env
|
|
443
|
+
|
|
444
|
+
\`\`\`bash
|
|
445
|
+
cp deploy/.env.production.example deploy/.env.production
|
|
446
|
+
\`\`\`
|
|
447
|
+
|
|
448
|
+
Fill \`deploy/.env.production\` with real values before public traffic. \`forge deploy check --production\` reads this file as deploy evidence, so you do not need to export every value into the shell. Do not commit real secrets.
|
|
449
|
+
|
|
450
|
+
The generated Docker Compose stack also reads \`deploy/.env.production\` through \`env_file\`. It does not inject a hidden \`DATABASE_URL\` override into the Forge runtime, migrate, or worker services.
|
|
451
|
+
|
|
452
|
+
Required production posture:
|
|
453
|
+
|
|
454
|
+
- \`FORGE_AUTH_MODE=jwt\` or \`FORGE_AUTH_MODE=oidc\`
|
|
455
|
+
- \`DATABASE_URL\` points at a production Postgres database
|
|
456
|
+
- issuer, audience, and JWKS/discovery settings match the auth provider
|
|
457
|
+
- any provider secrets listed in the example are set in the deployment secret store
|
|
458
|
+
|
|
459
|
+
## 2. Prove the app before traffic
|
|
460
|
+
|
|
461
|
+
\`\`\`bash
|
|
462
|
+
forge generate --check --json
|
|
463
|
+
forge check --json
|
|
464
|
+
forge authmd generate --json
|
|
465
|
+
forge auth prove --scenario multi-tenant --json
|
|
466
|
+
forge field-test run --runtime-probes --auth-probes --json
|
|
467
|
+
forge deploy check --production --json
|
|
468
|
+
\`\`\`
|
|
469
|
+
|
|
470
|
+
\`forge deploy check --production\` requires real deploy env evidence. A template file alone is not enough.
|
|
471
|
+
|
|
472
|
+
## 3. Run Docker
|
|
473
|
+
|
|
474
|
+
\`\`\`bash
|
|
475
|
+
docker compose -f deploy/docker-compose.yml up --build
|
|
476
|
+
\`\`\`
|
|
477
|
+
|
|
478
|
+
## 4. Verify the public runtime
|
|
479
|
+
|
|
480
|
+
\`\`\`bash
|
|
481
|
+
forge auth prove --prod --token <jwt> --json
|
|
482
|
+
forge deploy verify --production --url https://app.example.com --json
|
|
483
|
+
\`\`\`
|
|
484
|
+
|
|
485
|
+
\`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.
|
|
486
|
+
`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function buildPlan(options: DeployCommandOptions): DeployCommandResult {
|
|
490
|
+
const commands = options.target === "forge-cloud"
|
|
491
|
+
? [
|
|
492
|
+
"forge deploy check --production --json",
|
|
493
|
+
"forge auth check --production --json",
|
|
494
|
+
"forge authmd check --json",
|
|
495
|
+
"forge field-test run --runtime-probes --auth-probes --json",
|
|
496
|
+
"forge field-test report --json",
|
|
497
|
+
"forge deploy verify --production --url https://<your-forge-cloud-app> --json",
|
|
498
|
+
]
|
|
499
|
+
: [
|
|
500
|
+
"forge deploy render docker",
|
|
501
|
+
"cp deploy/.env.production.example deploy/.env.production",
|
|
502
|
+
"forge deploy check --production --json",
|
|
503
|
+
"forge field-test run --runtime-probes --auth-probes --json",
|
|
504
|
+
"docker compose -f deploy/docker-compose.yml up --build",
|
|
505
|
+
"forge deploy verify --production --url https://app.example.com --json",
|
|
506
|
+
];
|
|
507
|
+
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
508
|
+
schemaVersion: "0.1.0",
|
|
509
|
+
ok: true,
|
|
510
|
+
kind: "deploy",
|
|
511
|
+
action: "plan",
|
|
512
|
+
target: options.target,
|
|
513
|
+
production: options.production,
|
|
514
|
+
checks: [],
|
|
515
|
+
plan: {
|
|
516
|
+
summary: options.target === "forge-cloud"
|
|
517
|
+
? "Forge Cloud should be treated as a future managed target; current production proof still uses the same readiness gates."
|
|
518
|
+
: "Docker production deploy uses Postgres, explicit migration, runtime, worker, production auth, and public metadata checks.",
|
|
519
|
+
commands,
|
|
520
|
+
gates: [
|
|
521
|
+
"generated artifacts fresh",
|
|
522
|
+
"forge check passes",
|
|
523
|
+
"auth mode is jwt or oidc",
|
|
524
|
+
"production database env evidence is present",
|
|
525
|
+
"required secret names are present",
|
|
526
|
+
"auth.md and protected-resource metadata are published",
|
|
527
|
+
"field-test report exists with runtime/auth probes",
|
|
528
|
+
"runtime /health responds",
|
|
529
|
+
"tenant and policy proof is run before public traffic",
|
|
530
|
+
],
|
|
531
|
+
notes: [
|
|
532
|
+
"dev-headers auth is local-only and must not be enabled for public runtime.",
|
|
533
|
+
"Use forge workos doctor/seed when WorkOS is configured.",
|
|
534
|
+
"Use forge test authz or HTTP probes to prove cross-tenant denial.",
|
|
535
|
+
],
|
|
536
|
+
},
|
|
537
|
+
nextActions: commands,
|
|
538
|
+
exitCode: 0,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function buildChecks(options: DeployCommandOptions): Promise<DeployCommandResult> {
|
|
543
|
+
const checks: DeployCheck[] = [];
|
|
544
|
+
const generated = await runGenerate({
|
|
545
|
+
workspaceRoot: options.workspaceRoot,
|
|
546
|
+
check: true,
|
|
547
|
+
dryRun: false,
|
|
548
|
+
json: false,
|
|
549
|
+
concurrency: 4,
|
|
550
|
+
});
|
|
551
|
+
checks.push({
|
|
552
|
+
name: "generated",
|
|
553
|
+
ok: generated.exitCode === 0,
|
|
554
|
+
severity: "error",
|
|
555
|
+
message: generated.exitCode === 0 ? "generated artifacts are fresh" : "generated artifacts are stale; run forge generate",
|
|
556
|
+
command: "forge generate --check --json",
|
|
557
|
+
});
|
|
558
|
+
const packageManager = detectPackageManager(options.workspaceRoot);
|
|
559
|
+
const lockfiles = activeLockfiles(options.workspaceRoot, packageManager);
|
|
560
|
+
checks.push({
|
|
561
|
+
name: "package-lockfile",
|
|
562
|
+
ok: lockfiles.length > 0,
|
|
563
|
+
severity: options.production ? "error" : "warning",
|
|
564
|
+
message: lockfiles.length > 0
|
|
565
|
+
? `${packageManager} lockfile present: ${lockfiles.join(", ")}`
|
|
566
|
+
: `no ${packageManager} lockfile found; production Docker builds should be reproducible`,
|
|
567
|
+
command: packageInstallCommand(packageManager),
|
|
568
|
+
details: {
|
|
569
|
+
packageManager,
|
|
570
|
+
expected: getLockfileCandidates(packageManager),
|
|
571
|
+
found: lockfiles,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const auth = await withDeployEnv(options.workspaceRoot, async () => loadAuthConfigFromEnv(options.workspaceRoot));
|
|
576
|
+
const productionAuth = auth.mode === "jwt" || auth.mode === "oidc";
|
|
577
|
+
checks.push({
|
|
578
|
+
name: "production-auth-mode",
|
|
579
|
+
ok: productionAuth,
|
|
580
|
+
severity: "error",
|
|
581
|
+
message: productionAuth
|
|
582
|
+
? `auth mode ${auth.mode} is production-capable`
|
|
583
|
+
: `auth mode ${auth.mode} is local-only; set FORGE_AUTH_MODE=jwt or oidc`,
|
|
584
|
+
command: "forge auth check --production --json",
|
|
585
|
+
});
|
|
586
|
+
checks.push({
|
|
587
|
+
name: "auth-issuer",
|
|
588
|
+
ok: !productionAuth || Boolean(auth.issuer),
|
|
589
|
+
severity: "error",
|
|
590
|
+
message: !productionAuth || auth.issuer ? "issuer configured or not applicable" : "FORGE_AUTH_ISSUER is required",
|
|
591
|
+
});
|
|
592
|
+
checks.push({
|
|
593
|
+
name: "auth-audience",
|
|
594
|
+
ok: !productionAuth || Boolean(auth.audience),
|
|
595
|
+
severity: "error",
|
|
596
|
+
message: !productionAuth || auth.audience ? "audience configured or not applicable" : "FORGE_AUTH_AUDIENCE is required",
|
|
597
|
+
});
|
|
598
|
+
checks.push({
|
|
599
|
+
name: "auth-jwks",
|
|
600
|
+
ok: !productionAuth || auth.mode === "oidc" || Boolean(auth.jwksUri),
|
|
601
|
+
severity: "error",
|
|
602
|
+
message: !productionAuth || auth.mode === "oidc" || auth.jwksUri ? "JWKS/discovery configured or not applicable" : "FORGE_AUTH_JWKS_URI is required for jwt mode",
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
checks.push({
|
|
606
|
+
name: "database-url",
|
|
607
|
+
ok: databaseReady(options),
|
|
608
|
+
severity: "error",
|
|
609
|
+
message: databaseReadyMessage(options),
|
|
610
|
+
command: databaseReadyCommand(options),
|
|
611
|
+
});
|
|
612
|
+
checks.push({
|
|
613
|
+
name: "auth-md",
|
|
614
|
+
ok: nodeFileSystem.exists(join(options.workspaceRoot, "public/auth.md")),
|
|
615
|
+
severity: options.production ? "error" : "warning",
|
|
616
|
+
message: options.production
|
|
617
|
+
? "public/auth.md must be published before production traffic so agents can discover the protected resource contract"
|
|
618
|
+
: "public/auth.md should be published for agent-ready apps",
|
|
619
|
+
command: "forge authmd generate --json",
|
|
620
|
+
});
|
|
621
|
+
checks.push({
|
|
622
|
+
name: "oauth-protected-resource",
|
|
623
|
+
ok: nodeFileSystem.exists(join(options.workspaceRoot, "public/.well-known/oauth-protected-resource")),
|
|
624
|
+
severity: options.production ? "error" : "warning",
|
|
625
|
+
message: options.production
|
|
626
|
+
? "OAuth protected-resource metadata must be published before production traffic"
|
|
627
|
+
: "protected-resource metadata should be published for agent-ready apps",
|
|
628
|
+
command: "forge authmd generate --json",
|
|
629
|
+
});
|
|
630
|
+
const authMdCheck = runAuthMdCommand({
|
|
631
|
+
subcommand: "check",
|
|
632
|
+
workspaceRoot: options.workspaceRoot,
|
|
633
|
+
json: true,
|
|
634
|
+
});
|
|
635
|
+
checks.push({
|
|
636
|
+
name: "auth-md-check",
|
|
637
|
+
ok: authMdCheck.exitCode === 0,
|
|
638
|
+
severity: options.production ? "error" : "warning",
|
|
639
|
+
message: authMdCheck.exitCode === 0
|
|
640
|
+
? "auth.md and protected-resource metadata match the generated app contract"
|
|
641
|
+
: (authMdCheck.diagnostics[0]?.message ?? "auth.md is missing or stale"),
|
|
642
|
+
command: "forge authmd generate --json",
|
|
643
|
+
details: {
|
|
644
|
+
path: authMdCheck.path,
|
|
645
|
+
metadataPath: authMdCheck.metadataPath,
|
|
646
|
+
changed: authMdCheck.changed,
|
|
647
|
+
diagnostics: authMdCheck.diagnostics,
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
if (hasWorkOSIntegration(options.workspaceRoot)) {
|
|
651
|
+
const workosDoctor = runWorkOSCommand({
|
|
652
|
+
subcommand: "doctor",
|
|
653
|
+
workspaceRoot: options.workspaceRoot,
|
|
654
|
+
json: true,
|
|
655
|
+
yes: false,
|
|
656
|
+
dryRun: true,
|
|
657
|
+
});
|
|
658
|
+
checks.push({
|
|
659
|
+
name: "workos-doctor",
|
|
660
|
+
ok: workosDoctor.exitCode === 0,
|
|
661
|
+
severity: options.production ? "error" : "warning",
|
|
662
|
+
message: workosDoctor.exitCode === 0
|
|
663
|
+
? "WorkOS adapter files, seed, claims, policies, and FGA bridge are app-aware"
|
|
664
|
+
: "WorkOS adapter is incomplete for this app; run forge workos doctor --json",
|
|
665
|
+
command: "forge workos doctor --json",
|
|
666
|
+
details: workosDoctor.checks,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
const tenantProofRequired = auth.requiresTenant || hasWorkOSIntegration(options.workspaceRoot);
|
|
670
|
+
const tenantProof = tenantProofRequired
|
|
671
|
+
? await withDeployEnv(options.workspaceRoot, () =>
|
|
672
|
+
runAuthCommand({
|
|
673
|
+
subcommand: "prove",
|
|
674
|
+
workspaceRoot: options.workspaceRoot,
|
|
675
|
+
json: true,
|
|
676
|
+
prod: false,
|
|
677
|
+
scenario: "multi-tenant",
|
|
678
|
+
})
|
|
679
|
+
)
|
|
680
|
+
: null;
|
|
681
|
+
checks.push({
|
|
682
|
+
name: "auth-tenant-proof",
|
|
683
|
+
ok: tenantProofRequired ? tenantProof?.exitCode === 0 : true,
|
|
684
|
+
severity: tenantProofRequired && options.production ? "error" : "warning",
|
|
685
|
+
message: tenantProofRequired
|
|
686
|
+
? tenantProof?.exitCode === 0
|
|
687
|
+
? "local multi-tenant auth proof passed"
|
|
688
|
+
: "local multi-tenant auth proof failed; prove claim mapping, seed coverage, permissions, and auth metadata before production"
|
|
689
|
+
: "not required; app is not tenant-scoped and no WorkOS adapter was detected",
|
|
690
|
+
command: tenantProofRequired ? "forge auth prove --scenario multi-tenant --json" : undefined,
|
|
691
|
+
details: tenantProof?.data,
|
|
692
|
+
});
|
|
693
|
+
const fieldReports = fieldTestReportCandidates(options.workspaceRoot);
|
|
694
|
+
const latestFieldReport = fieldReports[0];
|
|
695
|
+
const fieldSummary = latestFieldReport ? summarizeFieldTestReport(latestFieldReport.data) : null;
|
|
696
|
+
checks.push({
|
|
697
|
+
name: "field-test-report",
|
|
698
|
+
ok: Boolean(fieldSummary?.ok && fieldSummary.runtimeProbes && fieldSummary.authProbes),
|
|
699
|
+
severity: options.production ? "error" : "warning",
|
|
700
|
+
message: fieldSummary?.ok
|
|
701
|
+
? fieldSummary.runtimeProbes && fieldSummary.authProbes
|
|
702
|
+
? `field-test report ${latestFieldReport?.path} passed with runtime and auth probes`
|
|
703
|
+
: `field-test report ${latestFieldReport?.path} passed but is missing runtime/auth probes`
|
|
704
|
+
: latestFieldReport
|
|
705
|
+
? `field-test report ${latestFieldReport.path} did not pass`
|
|
706
|
+
: "no field-test report found; run forge field-test run --runtime-probes --auth-probes --json",
|
|
707
|
+
command: "forge field-test run --runtime-probes --auth-probes --json",
|
|
708
|
+
details: latestFieldReport ? { path: latestFieldReport.path, summary: fieldSummary } : undefined,
|
|
709
|
+
});
|
|
710
|
+
checks.push({
|
|
711
|
+
name: "frontend-build-script",
|
|
712
|
+
ok: !readGeneratedJson<FrontendGraph>(options.workspaceRoot, `${GENERATED_DIR}/frontendGraph.json`)?.present || hasScript(options.workspaceRoot, "build") || hasScript(join(options.workspaceRoot, "web"), "build"),
|
|
713
|
+
severity: "warning",
|
|
714
|
+
message: "frontend apps should expose a production build script",
|
|
715
|
+
});
|
|
716
|
+
checks.push({
|
|
717
|
+
name: "tenant-claim",
|
|
718
|
+
ok: !auth.requiresTenant || Boolean(auth.claims.tenantId),
|
|
719
|
+
severity: "error",
|
|
720
|
+
message: !auth.requiresTenant || auth.claims.tenantId ? "tenant claim mapping is present or not required" : "tenant-scoped production apps require a tenant claim mapping",
|
|
721
|
+
});
|
|
722
|
+
checks.push({
|
|
723
|
+
name: "live-production",
|
|
724
|
+
ok: true,
|
|
725
|
+
severity: "warning",
|
|
726
|
+
message: "production liveQuery must use durable invalidations; verify with forge inspect live-production --json",
|
|
727
|
+
command: "forge inspect live-production --json",
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const errorFree = checks.every((check) => check.ok || check.severity === "warning");
|
|
731
|
+
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
732
|
+
schemaVersion: "0.1.0",
|
|
733
|
+
ok: errorFree,
|
|
734
|
+
kind: "deploy",
|
|
735
|
+
action: "check",
|
|
736
|
+
target: options.target,
|
|
737
|
+
production: options.production,
|
|
738
|
+
checks,
|
|
739
|
+
nextActions: errorFree
|
|
740
|
+
? ["forge deploy verify --production --url https://app.example.com --json"]
|
|
741
|
+
: unique(checks.flatMap((check) => !check.ok && check.command ? [check.command] : [])),
|
|
742
|
+
exitCode: errorFree ? 0 : 1,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function renderDocker(options: DeployCommandOptions): DeployCommandResult {
|
|
747
|
+
const dir = join(options.workspaceRoot, "deploy");
|
|
748
|
+
nodeFileSystem.mkdirp(dir);
|
|
749
|
+
const files = [
|
|
750
|
+
["deploy/docker-compose.yml", renderDockerCompose(options.workspaceRoot)],
|
|
751
|
+
["deploy/Dockerfile.runtime", renderRuntimeDockerfile(options.workspaceRoot)],
|
|
752
|
+
["deploy/.env.production.example", renderEnvExample(options.workspaceRoot)],
|
|
753
|
+
["deploy/README.production.md", renderProductionReadme()],
|
|
754
|
+
] as const;
|
|
755
|
+
for (const [file, content] of files) {
|
|
756
|
+
nodeFileSystem.writeText(join(options.workspaceRoot, file), content);
|
|
757
|
+
}
|
|
758
|
+
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
759
|
+
schemaVersion: "0.1.0",
|
|
760
|
+
ok: true,
|
|
761
|
+
kind: "deploy",
|
|
762
|
+
action: "render",
|
|
763
|
+
target: options.target,
|
|
764
|
+
production: options.production,
|
|
765
|
+
checks: [],
|
|
766
|
+
files: files.map(([file]) => file),
|
|
767
|
+
nextActions: [
|
|
768
|
+
"cp deploy/.env.production.example deploy/.env.production",
|
|
769
|
+
"forge deploy check --production --json",
|
|
770
|
+
"docker compose -f deploy/docker-compose.yml up --build",
|
|
771
|
+
],
|
|
772
|
+
exitCode: 0,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function probe(
|
|
777
|
+
method: "GET" | "HEAD",
|
|
778
|
+
url: string,
|
|
779
|
+
expectation: DeployProbeExpectation = {},
|
|
780
|
+
): Promise<{ method: string; url: string; ok: boolean; status?: number; contentType?: string; error?: string; jsonValid?: boolean }> {
|
|
781
|
+
try {
|
|
782
|
+
const response = await fetch(url, { method });
|
|
783
|
+
const contentType = response.headers.get("content-type") ?? undefined;
|
|
784
|
+
const contentTypeOk = !expectation.contentTypeIncludes || contentType?.includes(expectation.contentTypeIncludes) === true;
|
|
785
|
+
let jsonValid: boolean | undefined;
|
|
786
|
+
let bodyError: string | undefined;
|
|
787
|
+
if (expectation.json && method !== "HEAD") {
|
|
788
|
+
const text = await response.text();
|
|
789
|
+
try {
|
|
790
|
+
JSON.parse(text);
|
|
791
|
+
jsonValid = true;
|
|
792
|
+
} catch (error) {
|
|
793
|
+
jsonValid = false;
|
|
794
|
+
bodyError = error instanceof Error ? error.message : String(error);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const ok = response.ok && contentTypeOk && (jsonValid !== false);
|
|
798
|
+
return {
|
|
799
|
+
method,
|
|
800
|
+
url,
|
|
801
|
+
ok,
|
|
802
|
+
status: response.status,
|
|
803
|
+
contentType,
|
|
804
|
+
...(jsonValid !== undefined ? { jsonValid } : {}),
|
|
805
|
+
...(!contentTypeOk
|
|
806
|
+
? { error: `expected content-type including ${expectation.contentTypeIncludes}; received ${contentType ?? "none"}` }
|
|
807
|
+
: bodyError
|
|
808
|
+
? { error: `invalid JSON: ${bodyError}` }
|
|
809
|
+
: {}),
|
|
810
|
+
};
|
|
811
|
+
} catch (error) {
|
|
812
|
+
return {
|
|
813
|
+
method,
|
|
814
|
+
url,
|
|
815
|
+
ok: false,
|
|
816
|
+
error: error instanceof Error ? error.message : String(error),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function verifyUrl(options: DeployCommandOptions): Promise<DeployCommandResult> {
|
|
822
|
+
const base = options.url?.replace(/\/$/, "");
|
|
823
|
+
if (!base) {
|
|
824
|
+
return {
|
|
825
|
+
schemaVersion: "0.1.0",
|
|
826
|
+
ok: false,
|
|
827
|
+
kind: "deploy",
|
|
828
|
+
action: "verify",
|
|
829
|
+
target: options.target,
|
|
830
|
+
production: options.production,
|
|
831
|
+
checks: [{ name: "url", ok: false, severity: "error", message: "--url is required" }],
|
|
832
|
+
nextActions: ["forge deploy verify --url https://app.example.com --json"],
|
|
833
|
+
exitCode: 1,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
const probes = await Promise.all([
|
|
837
|
+
probe("GET", `${base}/health`),
|
|
838
|
+
probe("HEAD", `${base}/auth.md`, { contentTypeIncludes: "text/markdown" }),
|
|
839
|
+
probe("GET", `${base}/auth.md`, { contentTypeIncludes: "text/markdown" }),
|
|
840
|
+
probe("HEAD", `${base}/.well-known/oauth-protected-resource`, { contentTypeIncludes: "application/json" }),
|
|
841
|
+
probe("GET", `${base}/.well-known/oauth-protected-resource`, { contentTypeIncludes: "application/json", json: true }),
|
|
842
|
+
]);
|
|
843
|
+
const checks = probes.map((item): DeployCheck => ({
|
|
844
|
+
name: `${item.method} ${item.url.replace(base, "") || "/"}`,
|
|
845
|
+
ok: item.ok,
|
|
846
|
+
severity: item.url.endsWith("/health") || options.production ? "error" : "warning",
|
|
847
|
+
message: item.ok
|
|
848
|
+
? `HTTP ${item.status}${item.contentType ? ` ${item.contentType}` : ""}`
|
|
849
|
+
: item.error ?? `HTTP ${item.status ?? "failed"}`,
|
|
850
|
+
}));
|
|
851
|
+
const ok = checks.every((check) => check.ok || check.severity === "warning");
|
|
852
|
+
return {
|
|
853
|
+
schemaVersion: "0.1.0",
|
|
854
|
+
ok,
|
|
855
|
+
kind: "deploy",
|
|
856
|
+
action: "verify",
|
|
857
|
+
target: options.target,
|
|
858
|
+
production: options.production,
|
|
859
|
+
checks,
|
|
860
|
+
probes,
|
|
861
|
+
nextActions: ok ? ["forge handoff --json"] : ["check runtime logs", "forge deploy check --production --json"],
|
|
862
|
+
exitCode: ok ? 0 : 1,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export async function runDeployCommand(options: DeployCommandOptions): Promise<DeployCommandResult> {
|
|
867
|
+
if (options.subcommand === "plan") return buildPlan(options);
|
|
868
|
+
if (options.subcommand === "check") return buildChecks(options);
|
|
869
|
+
if (options.subcommand === "render") return renderDocker(options);
|
|
870
|
+
return verifyUrl(options);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export function formatDeployJson(result: DeployCommandResult): string {
|
|
874
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function formatDeployHuman(result: DeployCommandResult): string {
|
|
878
|
+
const lines = [
|
|
879
|
+
`deploy ${result.action} ${result.ok ? "ok" : "failed"}`,
|
|
880
|
+
`target: ${result.target}`,
|
|
881
|
+
...result.checks.map((check) => `${check.ok ? "ok" : check.severity === "warning" ? "warn" : "fail"} ${check.name}: ${check.message}`),
|
|
882
|
+
...(result.files?.length ? ["", "Files:", ...result.files.map((file) => ` ${file}`)] : []),
|
|
883
|
+
...(result.plan ? ["", result.plan.summary, "", "Commands:", ...result.plan.commands.map((command) => ` ${command}`)] : []),
|
|
884
|
+
...(result.nextActions.length ? ["", "Next:", ...result.nextActions.map((action) => ` ${action}`)] : []),
|
|
885
|
+
];
|
|
886
|
+
return `${lines.join("\n")}\n`;
|
|
887
|
+
}
|