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
package/src/forge/cli/docs.ts
CHANGED
|
@@ -47,7 +47,7 @@ const CONTENT_CHECKS: Array<{ file: string; contains: string[] }> = [
|
|
|
47
47
|
{ file: "docs/agent-workflow.md", contains: ["forge do", "forge status --json", "forge handoff --json"] },
|
|
48
48
|
{ file: "docs/troubleshooting.md", contains: ["FORGE_DELTA_BUSY", "waiting-for-user-trust", "Studio target preview issues"] },
|
|
49
49
|
{ file: "docs/release.md", contains: ["release:verify-public-alpha", "Documentation checklist"] },
|
|
50
|
-
{ file: "docs/self-host.md", contains: ["forge
|
|
50
|
+
{ file: "docs/self-host.md", contains: ["forge deploy check --production --json"] },
|
|
51
51
|
];
|
|
52
52
|
|
|
53
53
|
function read(workspaceRoot: string, relative: string): string | null {
|
package/src/forge/cli/doctor.ts
CHANGED
|
@@ -30,6 +30,41 @@ export interface PgliteDoctorResult {
|
|
|
30
30
|
ok: boolean;
|
|
31
31
|
inspection: PgliteStoreInspection;
|
|
32
32
|
checks: DoctorCheck[];
|
|
33
|
+
dbGuide: LocalDbModeGuide;
|
|
34
|
+
nextActions: string[];
|
|
35
|
+
exitCode: 0 | 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LocalDbModeGuide {
|
|
39
|
+
recommendedForCurrentState: "pglite" | "memory" | "stop-active-process" | "repair-pglite";
|
|
40
|
+
memory: {
|
|
41
|
+
command: string;
|
|
42
|
+
useWhen: string[];
|
|
43
|
+
tradeoff: string;
|
|
44
|
+
};
|
|
45
|
+
pglite: {
|
|
46
|
+
command: string;
|
|
47
|
+
useWhen: string[];
|
|
48
|
+
tradeoff: string;
|
|
49
|
+
};
|
|
50
|
+
repair?: {
|
|
51
|
+
command: string;
|
|
52
|
+
safeWhen: string;
|
|
53
|
+
preservesDataByArchiving: boolean;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RuntimeDoctorResult {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
checks: DoctorCheck[];
|
|
60
|
+
dev: {
|
|
61
|
+
pidFile: string;
|
|
62
|
+
logFile: string;
|
|
63
|
+
running: boolean;
|
|
64
|
+
pid?: number;
|
|
65
|
+
};
|
|
66
|
+
pglite: PgliteStoreInspection;
|
|
67
|
+
dbGuide: LocalDbModeGuide;
|
|
33
68
|
nextActions: string[];
|
|
34
69
|
exitCode: 0 | 1;
|
|
35
70
|
}
|
|
@@ -106,6 +141,50 @@ function frontendDevAuthChecks(
|
|
|
106
141
|
}));
|
|
107
142
|
}
|
|
108
143
|
|
|
144
|
+
function buildLocalDbModeGuide(workspaceRoot: string, pglite: PgliteStoreInspection): LocalDbModeGuide {
|
|
145
|
+
const memoryCommand = "forge dev --db memory --json";
|
|
146
|
+
const pgliteCommand = "forge dev --db pglite --json";
|
|
147
|
+
const repairCommand = "forge db repair --local --adapter pglite --json";
|
|
148
|
+
const recommendedForCurrentState: LocalDbModeGuide["recommendedForCurrentState"] =
|
|
149
|
+
pglite.state === "active"
|
|
150
|
+
? "stop-active-process"
|
|
151
|
+
: pglite.state === "aborted" || pglite.state === "unhealthy"
|
|
152
|
+
? "repair-pglite"
|
|
153
|
+
: pglite.openable
|
|
154
|
+
? "pglite"
|
|
155
|
+
: "memory";
|
|
156
|
+
return normalizeForgeCliCommandsInValue(workspaceRoot, {
|
|
157
|
+
recommendedForCurrentState,
|
|
158
|
+
memory: {
|
|
159
|
+
command: memoryCommand,
|
|
160
|
+
useWhen: [
|
|
161
|
+
"you need a clean isolated smoke test",
|
|
162
|
+
"PGlite is active, aborted, or suspected corrupt",
|
|
163
|
+
"you do not need local data to persist after the process exits",
|
|
164
|
+
],
|
|
165
|
+
tradeoff: "Fast and isolated, but data disappears when the dev process exits.",
|
|
166
|
+
},
|
|
167
|
+
pglite: {
|
|
168
|
+
command: pgliteCommand,
|
|
169
|
+
useWhen: [
|
|
170
|
+
"you want local data to persist between dev runs",
|
|
171
|
+
"you are reproducing real database behavior more closely than memory",
|
|
172
|
+
"doctor pglite reports the store is missing or healthy",
|
|
173
|
+
],
|
|
174
|
+
tradeoff: "Persists local state, but a stale/corrupt .forge/pglite store can require repair.",
|
|
175
|
+
},
|
|
176
|
+
...(pglite.state === "aborted" || pglite.state === "unhealthy"
|
|
177
|
+
? {
|
|
178
|
+
repair: {
|
|
179
|
+
command: repairCommand,
|
|
180
|
+
safeWhen: "PGlite is not owned by a live forge dev process and doctor reports aborted/unhealthy.",
|
|
181
|
+
preservesDataByArchiving: true,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
: {}),
|
|
185
|
+
}) as LocalDbModeGuide;
|
|
186
|
+
}
|
|
187
|
+
|
|
109
188
|
export async function runDoctorCommand(options: {
|
|
110
189
|
workspaceRoot: string;
|
|
111
190
|
}): Promise<DoctorResult> {
|
|
@@ -242,15 +321,116 @@ export async function runPgliteDoctorCommand(options: {
|
|
|
242
321
|
}
|
|
243
322
|
|
|
244
323
|
const exitOk = checks.every((check) => check.ok || check.severity === "warning");
|
|
324
|
+
const dbGuide = buildLocalDbModeGuide(options.workspaceRoot, inspection);
|
|
245
325
|
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
246
326
|
ok: exitOk,
|
|
247
327
|
inspection,
|
|
248
328
|
checks,
|
|
329
|
+
dbGuide,
|
|
249
330
|
nextActions: inspection.nextActions,
|
|
250
331
|
exitCode: exitOk ? 0 : 1,
|
|
251
332
|
});
|
|
252
333
|
}
|
|
253
334
|
|
|
335
|
+
function isProcessRunning(pid: number): boolean {
|
|
336
|
+
try {
|
|
337
|
+
process.kill(pid, 0);
|
|
338
|
+
return true;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function readPid(path: string): number | undefined {
|
|
345
|
+
const text = nodeFileSystem.readText(path);
|
|
346
|
+
if (text === null) return undefined;
|
|
347
|
+
const pid = Number(text.trim());
|
|
348
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function runRuntimeDoctorCommand(options: {
|
|
352
|
+
workspaceRoot: string;
|
|
353
|
+
}): Promise<RuntimeDoctorResult> {
|
|
354
|
+
const checks: DoctorCheck[] = [];
|
|
355
|
+
const generated = await runGenerate({
|
|
356
|
+
workspaceRoot: options.workspaceRoot,
|
|
357
|
+
check: true,
|
|
358
|
+
dryRun: false,
|
|
359
|
+
json: false,
|
|
360
|
+
concurrency: 4,
|
|
361
|
+
});
|
|
362
|
+
checks.push({
|
|
363
|
+
name: "generated",
|
|
364
|
+
ok: generated.exitCode === 0,
|
|
365
|
+
severity: "error",
|
|
366
|
+
message: generated.exitCode === 0 ? "generated artifacts are fresh" : "generated artifacts are stale; run forge generate",
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const devDir = join(options.workspaceRoot, ".forge", "dev");
|
|
370
|
+
const pidFile = join(devDir, "dev.pid");
|
|
371
|
+
const logFile = join(devDir, "dev.log");
|
|
372
|
+
const pid = readPid(pidFile);
|
|
373
|
+
const running = pid !== undefined && isProcessRunning(pid);
|
|
374
|
+
checks.push({
|
|
375
|
+
name: "dev-lifecycle",
|
|
376
|
+
ok: true,
|
|
377
|
+
severity: "warning",
|
|
378
|
+
message: running
|
|
379
|
+
? `detached forge dev is running with pid ${pid}`
|
|
380
|
+
: "no detached forge dev server is running",
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const pglite = await inspectPgliteStore(join(options.workspaceRoot, DEFAULT_PGLITE_DIR));
|
|
384
|
+
const pgliteOk = pglite.state === "missing" || pglite.state === "healthy" || pglite.state === "active";
|
|
385
|
+
checks.push({
|
|
386
|
+
name: "pglite-store",
|
|
387
|
+
ok: pgliteOk,
|
|
388
|
+
severity: pglite.state === "active" ? "warning" : "error",
|
|
389
|
+
message: pglite.error ?? `PGlite store is ${pglite.state}`,
|
|
390
|
+
});
|
|
391
|
+
checks.push({
|
|
392
|
+
name: "pglite-openable",
|
|
393
|
+
ok: pglite.openable || pglite.state === "active",
|
|
394
|
+
severity: pglite.state === "active" ? "warning" : "error",
|
|
395
|
+
message: pglite.openable
|
|
396
|
+
? "PGlite store is openable"
|
|
397
|
+
: pglite.state === "active"
|
|
398
|
+
? "PGlite store is owned by a live process"
|
|
399
|
+
: "PGlite store cannot be opened",
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const ok = checks.every((check) => check.ok || check.severity === "warning");
|
|
403
|
+
const dbGuide = buildLocalDbModeGuide(options.workspaceRoot, pglite);
|
|
404
|
+
const nextActions = ok
|
|
405
|
+
? ["forge dev --once --json", "forge dev status --json"]
|
|
406
|
+
: [
|
|
407
|
+
...(generated.exitCode === 0 ? [] : ["forge generate"]),
|
|
408
|
+
...(pglite.state === "active"
|
|
409
|
+
? ["stop the running forge dev process that owns .forge/pglite", "forge doctor pglite --json"]
|
|
410
|
+
: []),
|
|
411
|
+
...(pglite.state === "aborted" || pglite.state === "unhealthy"
|
|
412
|
+
? ["forge doctor pglite --json", "forge db repair --local --adapter pglite --json", "forge dev --db memory --json"]
|
|
413
|
+
: []),
|
|
414
|
+
...(!pglite.openable && pglite.state !== "active" && pglite.state !== "aborted" && pglite.state !== "unhealthy"
|
|
415
|
+
? ["forge doctor pglite --json", "forge dev --db memory --json"]
|
|
416
|
+
: []),
|
|
417
|
+
];
|
|
418
|
+
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
419
|
+
ok,
|
|
420
|
+
checks,
|
|
421
|
+
dev: {
|
|
422
|
+
pidFile,
|
|
423
|
+
logFile,
|
|
424
|
+
running,
|
|
425
|
+
...(pid !== undefined ? { pid } : {}),
|
|
426
|
+
},
|
|
427
|
+
pglite,
|
|
428
|
+
dbGuide,
|
|
429
|
+
nextActions: [...new Set(nextActions)],
|
|
430
|
+
exitCode: ok ? 0 : 1,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
254
434
|
export function formatDoctorJson(result: DoctorResult): string {
|
|
255
435
|
return `${JSON.stringify(result, null, 2)}\n`;
|
|
256
436
|
}
|
|
@@ -279,6 +459,42 @@ export function formatPgliteDoctorHuman(result: PgliteDoctorResult): string {
|
|
|
279
459
|
lines.push("");
|
|
280
460
|
lines.push(`Store: ${result.inspection.dataDir}`);
|
|
281
461
|
lines.push(`State: ${result.inspection.state}`);
|
|
462
|
+
lines.push(`Recommended DB mode: ${result.dbGuide.recommendedForCurrentState}`);
|
|
463
|
+
lines.push(`Memory: ${result.dbGuide.memory.command} - ${result.dbGuide.memory.tradeoff}`);
|
|
464
|
+
lines.push(`PGlite: ${result.dbGuide.pglite.command} - ${result.dbGuide.pglite.tradeoff}`);
|
|
465
|
+
if (result.dbGuide.repair) {
|
|
466
|
+
lines.push(`Repair: ${result.dbGuide.repair.command}`);
|
|
467
|
+
}
|
|
468
|
+
if (result.nextActions.length > 0) {
|
|
469
|
+
lines.push("");
|
|
470
|
+
lines.push("Next actions:");
|
|
471
|
+
for (const action of result.nextActions) {
|
|
472
|
+
lines.push(` ${action}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return `${lines.join("\n")}\n`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function formatRuntimeDoctorJson(result: RuntimeDoctorResult): string {
|
|
479
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function formatRuntimeDoctorHuman(result: RuntimeDoctorResult): string {
|
|
483
|
+
const lines = ["Forge Runtime Doctor", ""];
|
|
484
|
+
for (const check of result.checks) {
|
|
485
|
+
const marker = check.ok ? "OK" : check.severity === "warning" ? "WARN" : "FAIL";
|
|
486
|
+
lines.push(`${marker} ${check.name}${check.message ? ` - ${check.message}` : ""}`);
|
|
487
|
+
}
|
|
488
|
+
lines.push("");
|
|
489
|
+
lines.push(`Dev pid file: ${result.dev.pidFile}`);
|
|
490
|
+
lines.push(`Dev log file: ${result.dev.logFile}`);
|
|
491
|
+
lines.push(`PGlite state: ${result.pglite.state}`);
|
|
492
|
+
lines.push(`Recommended DB mode: ${result.dbGuide.recommendedForCurrentState}`);
|
|
493
|
+
lines.push(`Memory: ${result.dbGuide.memory.command} - ${result.dbGuide.memory.tradeoff}`);
|
|
494
|
+
lines.push(`PGlite: ${result.dbGuide.pglite.command} - ${result.dbGuide.pglite.tradeoff}`);
|
|
495
|
+
if (result.dbGuide.repair) {
|
|
496
|
+
lines.push(`Repair: ${result.dbGuide.repair.command}`);
|
|
497
|
+
}
|
|
282
498
|
if (result.nextActions.length > 0) {
|
|
283
499
|
lines.push("");
|
|
284
500
|
lines.push("Next actions:");
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runNewCommand, type NewPackageManager, type NewTemplateName } from "./new.ts";
|
|
5
|
+
import { normalizeForgeCliCommandsInValue } from "../workspace/forge-cli.ts";
|
|
6
|
+
|
|
7
|
+
export type FieldTestSubcommand = "create" | "run" | "report";
|
|
8
|
+
|
|
9
|
+
export interface FieldTestCommandOptions {
|
|
10
|
+
subcommand: FieldTestSubcommand;
|
|
11
|
+
workspaceRoot: string;
|
|
12
|
+
json: boolean;
|
|
13
|
+
name?: string;
|
|
14
|
+
template: NewTemplateName;
|
|
15
|
+
templates?: NewTemplateName[];
|
|
16
|
+
packageManager: NewPackageManager;
|
|
17
|
+
packageManagers?: NewPackageManager[];
|
|
18
|
+
forgeSpec?: string;
|
|
19
|
+
auth?: "none" | "workos";
|
|
20
|
+
dryRun: boolean;
|
|
21
|
+
keep: boolean;
|
|
22
|
+
runtimeProbes: boolean;
|
|
23
|
+
authProbes: boolean;
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
writeReport?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FieldTestCommandResult {
|
|
29
|
+
schemaVersion: "0.1.0";
|
|
30
|
+
ok: boolean;
|
|
31
|
+
kind: "field-test";
|
|
32
|
+
action: FieldTestSubcommand;
|
|
33
|
+
data?: unknown;
|
|
34
|
+
summary?: unknown;
|
|
35
|
+
command?: string[];
|
|
36
|
+
reportPath?: string;
|
|
37
|
+
stdout?: string;
|
|
38
|
+
stderr?: string;
|
|
39
|
+
nextActions: string[];
|
|
40
|
+
exitCode: 0 | 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scriptPath(workspaceRoot: string): string {
|
|
44
|
+
return join(workspaceRoot, "scripts", "field-test-forgeos.mjs");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compact(text: string, limit = 12000): string {
|
|
48
|
+
if (text.length <= limit) return text;
|
|
49
|
+
const half = Math.floor(limit / 2);
|
|
50
|
+
return `${text.slice(0, half)}\n...[truncated ${text.length - limit} chars]...\n${text.slice(-half)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultReportPath(): string {
|
|
54
|
+
return ".forge/field-test-report.json";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseJsonOutput(text: string): unknown | null {
|
|
58
|
+
const trimmed = text.trim();
|
|
59
|
+
if (!trimmed) return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(trimmed) as unknown;
|
|
62
|
+
} catch {
|
|
63
|
+
const start = trimmed.indexOf("{");
|
|
64
|
+
const end = trimmed.lastIndexOf("}");
|
|
65
|
+
if (start === -1 || end <= start) return null;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function summarizeReport(data: unknown) {
|
|
75
|
+
const root = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
76
|
+
const results = Array.isArray(root.results) ? root.results as Array<Record<string, unknown>> : [];
|
|
77
|
+
const plannedCases = Array.isArray(root.cases) ? root.cases as Array<Record<string, unknown>> : [];
|
|
78
|
+
const failed = results.filter((result) => result.ok === false && result.skipped !== true);
|
|
79
|
+
const skipped = results.filter((result) => result.skipped === true);
|
|
80
|
+
const runtimeProbeSteps = results.flatMap((result) => {
|
|
81
|
+
const runtime = result.runtime && typeof result.runtime === "object"
|
|
82
|
+
? result.runtime as Record<string, unknown>
|
|
83
|
+
: {};
|
|
84
|
+
return Array.isArray(runtime.steps) ? runtime.steps as unknown[] : [];
|
|
85
|
+
});
|
|
86
|
+
const ok = root.ok === true;
|
|
87
|
+
const runtimeProbes = root.runtimeProbes === true;
|
|
88
|
+
const authProbes = root.authProbes === true;
|
|
89
|
+
const productionEvidenceMissing = [
|
|
90
|
+
...(!ok ? ["passing field-test report"] : []),
|
|
91
|
+
...(!runtimeProbes ? ["runtime probes"] : []),
|
|
92
|
+
...(!authProbes ? ["auth probes"] : []),
|
|
93
|
+
...(failed.length > 0 ? ["zero failed cases"] : []),
|
|
94
|
+
];
|
|
95
|
+
return {
|
|
96
|
+
ok,
|
|
97
|
+
cases: results.length || plannedCases.length,
|
|
98
|
+
executedCases: results.length,
|
|
99
|
+
plannedCases: plannedCases.length,
|
|
100
|
+
passed: results.filter((result) => result.ok === true && result.skipped !== true).length,
|
|
101
|
+
failed: failed.length,
|
|
102
|
+
skipped: skipped.length,
|
|
103
|
+
runtimeProbes,
|
|
104
|
+
authProbes,
|
|
105
|
+
runtimeProbeSteps: runtimeProbeSteps.length,
|
|
106
|
+
productionEvidence: {
|
|
107
|
+
readyForDeployCheck: productionEvidenceMissing.length === 0,
|
|
108
|
+
missing: productionEvidenceMissing,
|
|
109
|
+
deployCheckCommand: "forge deploy check --production --json",
|
|
110
|
+
note: "This proves field-test evidence only; deploy check still validates production auth, database, metadata, lockfile, and tenant posture.",
|
|
111
|
+
},
|
|
112
|
+
failedCases: failed.map((result) => ({
|
|
113
|
+
template: result.template,
|
|
114
|
+
packageManager: result.packageManager,
|
|
115
|
+
reason: result.reason,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function runCommandForOptions(templates: NewTemplateName[], packageManagers: NewPackageManager[]): string {
|
|
121
|
+
return [
|
|
122
|
+
"forge field-test run",
|
|
123
|
+
`--templates ${templates.join(",")}`,
|
|
124
|
+
`--package-managers ${packageManagers.join(",")}`,
|
|
125
|
+
"--runtime-probes",
|
|
126
|
+
"--auth-probes",
|
|
127
|
+
"--json",
|
|
128
|
+
].join(" ");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function createFieldTestApp(options: FieldTestCommandOptions): Promise<FieldTestCommandResult> {
|
|
132
|
+
if (!options.name) {
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: "0.1.0",
|
|
135
|
+
ok: false,
|
|
136
|
+
kind: "field-test",
|
|
137
|
+
action: "create",
|
|
138
|
+
data: { error: "forge field-test create requires an app name" },
|
|
139
|
+
nextActions: ["forge field-test create vendor-access --auth workos --template minimal-web --json"],
|
|
140
|
+
exitCode: 1,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (options.dryRun) {
|
|
144
|
+
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
145
|
+
schemaVersion: "0.1.0",
|
|
146
|
+
ok: true,
|
|
147
|
+
kind: "field-test",
|
|
148
|
+
action: "create",
|
|
149
|
+
data: {
|
|
150
|
+
dryRun: true,
|
|
151
|
+
name: options.name,
|
|
152
|
+
template: options.template,
|
|
153
|
+
packageManager: options.packageManager,
|
|
154
|
+
auth: options.auth,
|
|
155
|
+
command: `forge new ${options.name} --template ${options.template} --package-manager ${options.packageManager} --field-test --install`,
|
|
156
|
+
},
|
|
157
|
+
nextActions: [`forge new ${options.name} --template ${options.template} --package-manager ${options.packageManager} --field-test --install`],
|
|
158
|
+
exitCode: 0,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const created = await runNewCommand({
|
|
162
|
+
name: options.name,
|
|
163
|
+
template: options.template,
|
|
164
|
+
packageManager: options.packageManager,
|
|
165
|
+
install: true,
|
|
166
|
+
git: true,
|
|
167
|
+
fieldTest: options.auth === "workos",
|
|
168
|
+
forgePackageSpec: options.forgeSpec,
|
|
169
|
+
workspaceRoot: options.workspaceRoot,
|
|
170
|
+
});
|
|
171
|
+
const createdFieldTest = created.fieldTest?.steps ?? [];
|
|
172
|
+
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
173
|
+
schemaVersion: "0.1.0",
|
|
174
|
+
ok: created.exitCode === 0,
|
|
175
|
+
kind: "field-test",
|
|
176
|
+
action: "create",
|
|
177
|
+
data: {
|
|
178
|
+
...created,
|
|
179
|
+
auth: options.auth,
|
|
180
|
+
setup: options.auth === "workos"
|
|
181
|
+
? {
|
|
182
|
+
applied: createdFieldTest.length > 0 && createdFieldTest.every((step) => step.ok),
|
|
183
|
+
steps: createdFieldTest,
|
|
184
|
+
}
|
|
185
|
+
: {
|
|
186
|
+
applied: false,
|
|
187
|
+
steps: [],
|
|
188
|
+
note: "auth=none creates a plain app; pass --auth workos for WorkOS/AuthKit/auth.md field-test setup.",
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
nextActions: [
|
|
192
|
+
`cd ${created.targetDir}`,
|
|
193
|
+
`${options.packageManager} run forge -- agent onboard --target codex --json`,
|
|
194
|
+
`${options.packageManager} run forge -- generate`,
|
|
195
|
+
`${options.packageManager} run forge -- check --json`,
|
|
196
|
+
...(options.auth === "workos"
|
|
197
|
+
? [
|
|
198
|
+
`${options.packageManager} run forge -- authmd check --json`,
|
|
199
|
+
`${options.packageManager} run forge -- workos doctor --json`,
|
|
200
|
+
`${options.packageManager} run forge -- workos seed --file workos-seed.yml --dry-run --json`,
|
|
201
|
+
]
|
|
202
|
+
: []),
|
|
203
|
+
`${options.packageManager} run forge -- dev --once --json`,
|
|
204
|
+
`${options.packageManager} run forge -- verify --smoke --json`,
|
|
205
|
+
`${options.packageManager} run forge -- handoff --json`,
|
|
206
|
+
],
|
|
207
|
+
exitCode: created.exitCode,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function runHarness(options: FieldTestCommandOptions): FieldTestCommandResult {
|
|
212
|
+
const script = scriptPath(options.workspaceRoot);
|
|
213
|
+
if (!existsSync(script)) {
|
|
214
|
+
return {
|
|
215
|
+
schemaVersion: "0.1.0",
|
|
216
|
+
ok: false,
|
|
217
|
+
kind: "field-test",
|
|
218
|
+
action: "run",
|
|
219
|
+
data: { error: "field-test harness script not found", script },
|
|
220
|
+
nextActions: ["run from the ForgeOS framework checkout or use npm run field:test"],
|
|
221
|
+
exitCode: 1,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const reportPath = options.writeReport ?? (options.dryRun ? undefined : defaultReportPath());
|
|
225
|
+
const templates = options.templates?.length ? options.templates : [options.template];
|
|
226
|
+
const packageManagers = options.packageManagers?.length ? options.packageManagers : [options.packageManager];
|
|
227
|
+
const args = [
|
|
228
|
+
script,
|
|
229
|
+
"--templates",
|
|
230
|
+
templates.join(","),
|
|
231
|
+
"--package-managers",
|
|
232
|
+
packageManagers.join(","),
|
|
233
|
+
"--timeout-ms",
|
|
234
|
+
String(options.timeoutMs),
|
|
235
|
+
];
|
|
236
|
+
if (options.dryRun) args.push("--dry-run");
|
|
237
|
+
if (options.keep) args.push("--keep");
|
|
238
|
+
if (options.runtimeProbes) args.push("--runtime-probes");
|
|
239
|
+
if (options.authProbes) args.push("--auth-probes");
|
|
240
|
+
if (options.forgeSpec) args.push("--forge-spec", options.forgeSpec);
|
|
241
|
+
if (reportPath) args.push("--write-report", reportPath);
|
|
242
|
+
if (options.json) args.push("--json");
|
|
243
|
+
const result = spawnSync(process.execPath, args, {
|
|
244
|
+
cwd: options.workspaceRoot,
|
|
245
|
+
encoding: "utf8",
|
|
246
|
+
windowsHide: true,
|
|
247
|
+
});
|
|
248
|
+
const reportAbsolute = reportPath ? join(options.workspaceRoot, reportPath) : null;
|
|
249
|
+
const reportData = reportAbsolute && existsSync(reportAbsolute)
|
|
250
|
+
? JSON.parse(readFileSync(reportAbsolute, "utf8")) as unknown
|
|
251
|
+
: parseJsonOutput(result.stdout ?? "");
|
|
252
|
+
return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
|
|
253
|
+
schemaVersion: "0.1.0",
|
|
254
|
+
ok: result.status === 0,
|
|
255
|
+
kind: "field-test",
|
|
256
|
+
action: "run",
|
|
257
|
+
command: [process.execPath, ...args],
|
|
258
|
+
reportPath,
|
|
259
|
+
data: reportData ?? undefined,
|
|
260
|
+
summary: reportData ? summarizeReport(reportData) : undefined,
|
|
261
|
+
stdout: compact(result.stdout ?? ""),
|
|
262
|
+
stderr: compact(result.stderr ?? ""),
|
|
263
|
+
nextActions: result.status === 0
|
|
264
|
+
? options.dryRun
|
|
265
|
+
? [runCommandForOptions(templates, packageManagers)]
|
|
266
|
+
: [`forge field-test report --file ${reportPath ?? defaultReportPath()} --json`, "forge deploy check --production --json"]
|
|
267
|
+
: ["inspect field-test stdout/stderr", "forge doctor pglite --json"],
|
|
268
|
+
exitCode: result.status === 0 ? 0 : 1,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readReport(options: FieldTestCommandOptions): FieldTestCommandResult {
|
|
273
|
+
const candidates = [
|
|
274
|
+
options.writeReport,
|
|
275
|
+
".forge/field-test-report.json",
|
|
276
|
+
"field-reports/full-alpha.json",
|
|
277
|
+
].filter(Boolean) as string[];
|
|
278
|
+
const report = candidates.find((candidate) => existsSync(join(options.workspaceRoot, candidate)));
|
|
279
|
+
if (!report) {
|
|
280
|
+
return {
|
|
281
|
+
schemaVersion: "0.1.0",
|
|
282
|
+
ok: false,
|
|
283
|
+
kind: "field-test",
|
|
284
|
+
action: "report",
|
|
285
|
+
data: { searched: candidates },
|
|
286
|
+
nextActions: ["forge field-test run --runtime-probes --auth-probes --write-report .forge/field-test-report.json --json"],
|
|
287
|
+
exitCode: 1,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const data = JSON.parse(readFileSync(join(options.workspaceRoot, report), "utf8")) as unknown;
|
|
291
|
+
const summary = summarizeReport(data);
|
|
292
|
+
const productionEvidence = (summary as { productionEvidence?: { readyForDeployCheck?: boolean } }).productionEvidence;
|
|
293
|
+
return {
|
|
294
|
+
schemaVersion: "0.1.0",
|
|
295
|
+
ok: (data && typeof data === "object" && "ok" in data) ? (data as { ok?: unknown }).ok === true : true,
|
|
296
|
+
kind: "field-test",
|
|
297
|
+
action: "report",
|
|
298
|
+
reportPath: report,
|
|
299
|
+
summary,
|
|
300
|
+
data,
|
|
301
|
+
nextActions: productionEvidence?.readyForDeployCheck
|
|
302
|
+
? ["forge deploy check --production --json"]
|
|
303
|
+
: ["forge field-test run --runtime-probes --auth-probes --write-report .forge/field-test-report.json --json"],
|
|
304
|
+
exitCode: (data && typeof data === "object" && "ok" in data && (data as { ok?: unknown }).ok !== true) ? 1 : 0,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function runFieldTestCommand(options: FieldTestCommandOptions): Promise<FieldTestCommandResult> {
|
|
309
|
+
if (options.subcommand === "create") return createFieldTestApp(options);
|
|
310
|
+
if (options.subcommand === "run") return runHarness(options);
|
|
311
|
+
return readReport(options);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function formatFieldTestJson(result: FieldTestCommandResult): string {
|
|
315
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function formatFieldTestHuman(result: FieldTestCommandResult): string {
|
|
319
|
+
const lines = [
|
|
320
|
+
`field-test ${result.action} ${result.ok ? "ok" : "failed"}`,
|
|
321
|
+
...(result.reportPath ? [`report: ${result.reportPath}`] : []),
|
|
322
|
+
...(result.command ? [`command: ${result.command.join(" ")}`] : []),
|
|
323
|
+
...(result.stdout ? ["", result.stdout] : []),
|
|
324
|
+
...(result.stderr ? ["", result.stderr] : []),
|
|
325
|
+
...(result.nextActions.length ? ["", "Next:", ...result.nextActions.map((action) => ` ${action}`)] : []),
|
|
326
|
+
];
|
|
327
|
+
return `${lines.join("\n")}\n`;
|
|
328
|
+
}
|
package/src/forge/cli/handoff.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface HandoffCommandResult {
|
|
|
27
27
|
generatedChangedFiles: number;
|
|
28
28
|
frontendReady: boolean;
|
|
29
29
|
changedFiles: number;
|
|
30
|
+
commitReadyFiles: number;
|
|
30
31
|
stagedFiles: number;
|
|
31
32
|
unstagedFiles: number;
|
|
32
33
|
untrackedFiles: number;
|
|
@@ -157,10 +158,14 @@ function buildOpeningBrief(input: {
|
|
|
157
158
|
dev: DevConsoleCycle;
|
|
158
159
|
git: HandoffCommandResult["git"];
|
|
159
160
|
recentRuns: HandoffCommandResult["recentRuns"];
|
|
161
|
+
commitReady?: HandoffCommandResult["commitReady"];
|
|
160
162
|
}): string {
|
|
161
163
|
const agent = input.dev.summary.agentContext;
|
|
162
164
|
const changedByType = summarizeChangeTypes(input.git.changeSummary.changed);
|
|
163
165
|
const changedFiles = Math.max(agent.changedFiles, input.git.changed.count);
|
|
166
|
+
const commitReadyNote = input.commitReady && input.commitReady.count !== changedFiles
|
|
167
|
+
? `; ${input.commitReady.count} commit-ready file(s) after excluding generated/operational artifacts`
|
|
168
|
+
: "";
|
|
164
169
|
const tests = input.recentRuns.test
|
|
165
170
|
? input.recentRuns.test.ok
|
|
166
171
|
? "last test run passed"
|
|
@@ -171,7 +176,7 @@ function buildOpeningBrief(input: {
|
|
|
171
176
|
: "no blocking issues";
|
|
172
177
|
return [
|
|
173
178
|
`ForgeOS handoff: ${input.dev.ok ? "dev diagnostics are clean" : "dev diagnostics need attention"}.`,
|
|
174
|
-
`${changedFiles} changed file(s)${changedByType ? `: ${changedByType}` : ""}; ${input.git.staged.count} staged, ${input.git.untracked.count} untracked.`,
|
|
179
|
+
`${changedFiles} changed file(s)${changedByType ? `: ${changedByType}` : ""}${commitReadyNote}; ${input.git.staged.count} staged, ${input.git.untracked.count} untracked.`,
|
|
175
180
|
`${tests}; ${blockers}.`,
|
|
176
181
|
`Next command: ${input.dev.summary.primaryAction?.command ?? input.dev.nextActions[0]?.command ?? "forge dev"}.`,
|
|
177
182
|
].join(" ");
|
|
@@ -211,9 +216,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
|
|
|
211
216
|
includeImpact: true,
|
|
212
217
|
});
|
|
213
218
|
const git = buildWorkspaceGitSummary(workspaceRoot);
|
|
214
|
-
const commitReady =
|
|
215
|
-
? runChangedCommand(workspaceRoot, { commitReady: true }).data.commitReady as { count: number; files: string[] } | undefined
|
|
216
|
-
: undefined;
|
|
219
|
+
const commitReady = runChangedCommand(workspaceRoot, { commitReady: true }).data.commitReady as { count: number; files: string[] } | undefined;
|
|
217
220
|
const recentRuns = summarizeRecentRuns(workspaceRoot);
|
|
218
221
|
const agent = dev.summary.agentContext;
|
|
219
222
|
const risks = [
|
|
@@ -224,6 +227,9 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
|
|
|
224
227
|
? ["git status is unavailable; using filesystem inventory as untracked-file analysis"]
|
|
225
228
|
: []),
|
|
226
229
|
...(git.untracked.count > 0 && git.source !== "forge-baseline" ? [`${git.untracked.count} untracked file(s) are not in git history`] : []),
|
|
230
|
+
...(commitReady && git.changed.count > commitReady.count
|
|
231
|
+
? [`${git.changed.count - commitReady.count} generated or operational file(s) are excluded from the commit-ready view`]
|
|
232
|
+
: []),
|
|
227
233
|
...(recentRuns.test && !recentRuns.test.ok ? ["last test run failed"] : []),
|
|
228
234
|
...(recentRuns.ui && !recentRuns.ui.ok ? ["last UI run failed"] : []),
|
|
229
235
|
];
|
|
@@ -231,6 +237,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
|
|
|
231
237
|
...agent.recommendedCommands,
|
|
232
238
|
...forgeCliCommandsForWorkspace(workspaceRoot, [
|
|
233
239
|
...(git.changed.count > 0 ? ["forge review run --changed --json"] : []),
|
|
240
|
+
...(git.changed.count > 0 ? ["forge changed --commit-ready --json"] : []),
|
|
234
241
|
"forge handoff --json",
|
|
235
242
|
]),
|
|
236
243
|
];
|
|
@@ -252,6 +259,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
|
|
|
252
259
|
generatedChangedFiles: agent.generatedChangedFiles,
|
|
253
260
|
frontendReady: agent.frontendReady,
|
|
254
261
|
changedFiles,
|
|
262
|
+
commitReadyFiles: commitReady?.count ?? changedFiles,
|
|
255
263
|
stagedFiles: git.staged.count,
|
|
256
264
|
unstagedFiles: git.unstaged.count,
|
|
257
265
|
untrackedFiles: git.untracked.count,
|
|
@@ -273,7 +281,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
|
|
|
273
281
|
git,
|
|
274
282
|
recentRuns,
|
|
275
283
|
nextAgent: {
|
|
276
|
-
openingBrief: buildOpeningBrief({ dev, git, recentRuns }),
|
|
284
|
+
openingBrief: buildOpeningBrief({ dev, git, recentRuns, commitReady }),
|
|
277
285
|
recommendedReadFiles: agent.recommendedReadFiles,
|
|
278
286
|
recommendedCommands: [...new Set(nextActions)].slice(0, 10),
|
|
279
287
|
risks,
|