@capagents/forgeqe 1.3.2 → 2.0.1
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/HOW_TO_USE.md +373 -9
- package/README.md +55 -4
- package/docs/ARCHITECTURE.md +62 -1
- package/docs/CONTAINERS.md +227 -0
- package/docs/COST_ARCHITECTURE.md +32 -0
- package/docs/DESKTOP-CI.md +87 -0
- package/docs/DESKTOP.md +387 -0
- package/docs/MOBILE.md +228 -0
- package/docs/feature-test-prompts.md +25 -0
- package/docs/security-data-flow.html +440 -3
- package/docs/user-guide.html +797 -415
- package/env.example.yaml +67 -0
- package/forgeqe.example.yaml +56 -1
- package/llms.example.json +7 -0
- package/package.json +4 -1
- package/prompts/brain.md +31 -0
- package/prompts/find-tests.md +1 -0
- package/py/forgeqe_desktop/__init__.py +1 -0
- package/py/forgeqe_desktop/__main__.py +3 -0
- package/py/forgeqe_desktop/apps.py +80 -0
- package/py/forgeqe_desktop/checks.py +168 -0
- package/py/forgeqe_desktop/cli.py +90 -0
- package/py/forgeqe_desktop/handhooks.py +293 -0
- package/py/forgeqe_desktop/handrecord.py +239 -0
- package/py/forgeqe_desktop/heal.py +107 -0
- package/py/forgeqe_desktop/models.py +208 -0
- package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
- package/py/forgeqe_desktop/record.py +150 -0
- package/py/forgeqe_desktop/replay.py +345 -0
- package/py/forgeqe_desktop/service.py +356 -0
- package/py/forgeqe_desktop/session.py +60 -0
- package/py/forgeqe_desktop/ufo_bridge.py +86 -0
- package/py/forgeqe_desktop/ufo_runner.py +305 -0
- package/py/forgeqe_desktop/visual.py +112 -0
- package/py/forgeqe_desktop/workflow.py +338 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_mobile/__init__.py +1 -0
- package/py/forgeqe_mobile/__main__.py +3 -0
- package/py/forgeqe_mobile/appium_driver.py +342 -0
- package/py/forgeqe_mobile/apps.py +84 -0
- package/py/forgeqe_mobile/cli.py +70 -0
- package/py/forgeqe_mobile/explore.py +203 -0
- package/py/forgeqe_mobile/hyperexecute.py +261 -0
- package/py/forgeqe_mobile/maestro.py +321 -0
- package/py/forgeqe_mobile/service.py +332 -0
- package/skills/testcontainers/SKILL.md +42 -0
- package/src/agents.ts +11 -2
- package/src/api/plan.ts +2 -0
- package/src/api/write.ts +2 -0
- package/src/auth/copilot.ts +130 -0
- package/src/auth/signin.ts +54 -1
- package/src/brain/index.ts +26 -4
- package/src/capabilities/guide.ts +2 -0
- package/src/capabilities/registry.ts +110 -2
- package/src/capabilities/status.ts +15 -0
- package/src/cases/rca.ts +2 -0
- package/src/childEnv.ts +4 -1
- package/src/cli/args.ts +3 -2
- package/src/cli/help.ts +24 -3
- package/src/cli/index.ts +47 -3
- package/src/code/find.ts +26 -0
- package/src/code/loop.ts +4 -1
- package/src/code/prompt.ts +2 -1
- package/src/code/prreview.ts +2 -0
- package/src/code/review.ts +6 -2
- package/src/code/runtime.ts +34 -3
- package/src/commands.ts +6 -1
- package/src/config.ts +159 -3
- package/src/containers/command.ts +68 -0
- package/src/containers/compose.ts +173 -0
- package/src/containers/config.ts +283 -0
- package/src/containers/mock.ts +156 -0
- package/src/containers/run.ts +362 -0
- package/src/containers/tests.ts +80 -0
- package/src/context.ts +33 -6
- package/src/copilot.ts +155 -0
- package/src/desktop/bridge.ts +229 -0
- package/src/desktop/command.ts +350 -0
- package/src/desktop/edit.ts +217 -0
- package/src/desktop/junit.ts +35 -0
- package/src/desktop/report.ts +155 -0
- package/src/desktop/run.ts +252 -0
- package/src/desktop/setup.ts +134 -0
- package/src/engine/bridge.ts +12 -5
- package/src/engine/setup.ts +3 -3
- package/src/environments.ts +566 -0
- package/src/evals/domain.ts +1 -0
- package/src/explore/charters.ts +2 -0
- package/src/guardrails.ts +145 -0
- package/src/heal/loop.ts +4 -1
- package/src/heal/prompt.ts +2 -1
- package/src/init.ts +5 -2
- package/src/llm.ts +23 -2
- package/src/mcp/attachtools.ts +16 -4
- package/src/mcp/captools.ts +27 -0
- package/src/mcp/containertools.ts +212 -0
- package/src/mcp/desktoptools.ts +334 -0
- package/src/mcp/mobiletools.ts +132 -0
- package/src/mcp/reporttools.ts +17 -2
- package/src/mcp/repotests.ts +22 -3
- package/src/mcp/routing.ts +11 -5
- package/src/mcp/scope.ts +210 -0
- package/src/mcp/server.ts +80 -5
- package/src/mcp/tools.ts +38 -1
- package/src/mobile/command.ts +245 -0
- package/src/mobile/run.ts +226 -0
- package/src/pack/pipeline.ts +44 -2
- package/src/refine.ts +3 -0
- package/src/report/html.ts +3 -0
- package/src/report/template.html +48 -48
- package/src/run.ts +7 -3
- package/src/tokenomics/ledger.ts +3 -1
- package/src/tui/App.tsx +23 -0
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml"
|
|
5
|
+
|
|
6
|
+
import type { AppConfig, DesktopConfig, MobileConfig } from "./config.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Test environments (env.yaml, named in forgeqe.yaml `environments.file`): each has the apps a scenario can use (web,
|
|
10
|
+
* Windows desktop, mobile), plain variables, and the names of the secrets its apps' workflows may type. One is active
|
|
11
|
+
* (`--env`, FORGEQE_ENV, or the default); ForgeQE's agent picks the apps a request needs from it. No secret values
|
|
12
|
+
* belong in env.yaml: only the names of environment variables that hold them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type AppKind = "web" | "desktop" | "mobile" | "api"
|
|
16
|
+
|
|
17
|
+
export type TestApp = {
|
|
18
|
+
name: string
|
|
19
|
+
kind: AppKind
|
|
20
|
+
/** What the app is for, in the team's words: how the agent knows which app a step of a scenario belongs to. */
|
|
21
|
+
description?: string
|
|
22
|
+
/** web: where the app starts; api: the base URL its calls go to. */
|
|
23
|
+
url?: string
|
|
24
|
+
/** desktop: an absolute .exe, its arguments, and a main-window title pattern for launchers. */
|
|
25
|
+
path?: string
|
|
26
|
+
args?: string[]
|
|
27
|
+
window?: string
|
|
28
|
+
/** mobile: the platform and the app's package or bundle id; an Android activity, and an app file or upload to install. */
|
|
29
|
+
platform?: "android" | "ios"
|
|
30
|
+
appId?: string
|
|
31
|
+
activity?: string
|
|
32
|
+
appFile?: string
|
|
33
|
+
/** A saved login (logins: in forgeqe.yaml) the app signs in with. */
|
|
34
|
+
login?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Environment = {
|
|
38
|
+
name: string
|
|
39
|
+
description?: string
|
|
40
|
+
apps: Record<string, TestApp>
|
|
41
|
+
/** The web app browser runs start at when no URL is given (start_app; else the only web app). */
|
|
42
|
+
startApp?: string
|
|
43
|
+
/** The API app API tests call (api_app; else the only API app). */
|
|
44
|
+
apiApp?: string
|
|
45
|
+
/** Plain values a scenario may use (a company name, a test customer); never secrets. */
|
|
46
|
+
variables: Record<string, string>
|
|
47
|
+
/** Environment variables this environment's workflows may type with {{secret:NAME}}. */
|
|
48
|
+
secrets: string[]
|
|
49
|
+
/**
|
|
50
|
+
* The environment as the team wrote it, when env.yaml is in its own layout rather than ForgeQE's: YAML with every
|
|
51
|
+
* secret value replaced, for the agents to read the names and values the team uses (a db_server, a tenant id).
|
|
52
|
+
*/
|
|
53
|
+
details?: string
|
|
54
|
+
/** Secret values written into the file itself: never used or shown, and reported so they can be moved out. */
|
|
55
|
+
literalSecrets?: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type EnvironmentsConfig = {
|
|
59
|
+
file: string
|
|
60
|
+
/** The environment ForgeQE uses unless a request names another. */
|
|
61
|
+
active?: string
|
|
62
|
+
all: Record<string, Environment>
|
|
63
|
+
/** desktop: from forgeqe.yaml alone, before an environment's apps are added (switching environments starts from it). */
|
|
64
|
+
baseDesktop: DesktopConfig
|
|
65
|
+
/** mobile: from forgeqe.yaml alone, likewise. */
|
|
66
|
+
baseMobile?: MobileConfig
|
|
67
|
+
/** browser.allowed_hosts, browser.start_url and api.base_url from forgeqe.yaml alone, likewise. */
|
|
68
|
+
baseHosts: string[]
|
|
69
|
+
baseStartUrl?: string
|
|
70
|
+
baseApiUrl?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
|
|
74
|
+
const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
|
|
75
|
+
|
|
76
|
+
function record(value: unknown, where: string): Record<string, unknown> {
|
|
77
|
+
if (value === undefined || value === null) return {}
|
|
78
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${where} must be a mapping`)
|
|
79
|
+
return value as Record<string, unknown>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function text(value: unknown, where: string): string | undefined {
|
|
83
|
+
if (value === undefined || value === null || value === "") return undefined
|
|
84
|
+
if (typeof value !== "string" && typeof value !== "number") throw new Error(`${where} must be text`)
|
|
85
|
+
return String(value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function webUrl(raw: string, where: string): string {
|
|
89
|
+
let url: URL
|
|
90
|
+
try {
|
|
91
|
+
url = new URL(raw)
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`${where} must be a full http(s) URL, e.g. https://qa.shop.example.com`)
|
|
94
|
+
}
|
|
95
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`${where} must be an http(s) URL, not ${url.protocol}`)
|
|
96
|
+
if (url.username || url.password) throw new Error(`${where} must not carry a user name or password; use logins: in forgeqe.yaml`)
|
|
97
|
+
return url.href
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseApp(name: string, raw: unknown, where: string): TestApp {
|
|
101
|
+
if (!NAME.test(name)) throw new Error(`${where}: app names are letters, digits and _ . -`)
|
|
102
|
+
const r = record(raw, where)
|
|
103
|
+
const kind = String(r.kind || "")
|
|
104
|
+
if (kind !== "web" && kind !== "desktop" && kind !== "mobile" && kind !== "api") throw new Error(`${where}.kind must be web, desktop, mobile or api`)
|
|
105
|
+
const app: TestApp = { name, kind, ...(text(r.description, `${where}.description`) ? { description: text(r.description, `${where}.description`) } : {}) }
|
|
106
|
+
const login = text(r.login, `${where}.login`)
|
|
107
|
+
if (login) app.login = login
|
|
108
|
+
if (kind === "web" || kind === "api") {
|
|
109
|
+
const url = text(r.url, `${where}.url`)
|
|
110
|
+
if (!url) throw new Error(`${where}.url is required for a${kind === "api" ? "n api" : " web"} app`)
|
|
111
|
+
app.url = webUrl(url, `${where}.url`)
|
|
112
|
+
} else if (kind === "desktop") {
|
|
113
|
+
const p = text(r.path, `${where}.path`)
|
|
114
|
+
if (!p) throw new Error(`${where}.path is required for a desktop app (an absolute path to its .exe)`)
|
|
115
|
+
if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(p) || !/\.exe$/i.test(p) || /[\x00-\x1f]/.test(p)) {
|
|
116
|
+
throw new Error(`${where}.path must be an absolute path to an .exe; .bat, .cmd and scripts run through a shell`)
|
|
117
|
+
}
|
|
118
|
+
app.path = p
|
|
119
|
+
const args = r.args ?? []
|
|
120
|
+
if (!Array.isArray(args) || !args.every((a) => typeof a === "string" || typeof a === "number")) {
|
|
121
|
+
throw new Error(`${where}.args must be a list of arguments, not one command string`)
|
|
122
|
+
}
|
|
123
|
+
app.args = args.map(String)
|
|
124
|
+
const window = text(r.window, `${where}.window`)
|
|
125
|
+
if (window) app.window = window
|
|
126
|
+
} else {
|
|
127
|
+
const platform = String(r.platform || "")
|
|
128
|
+
if (platform !== "android" && platform !== "ios") throw new Error(`${where}.platform must be android or ios`)
|
|
129
|
+
const appId = text(r.app_id ?? r.appId, `${where}.app_id`)
|
|
130
|
+
if (!appId || !/^[A-Za-z0-9_.-]+$/.test(appId)) throw new Error(`${where}.app_id must be the app's package or bundle id`)
|
|
131
|
+
app.platform = platform
|
|
132
|
+
app.appId = appId
|
|
133
|
+
const activity = text(r.activity, `${where}.activity`)
|
|
134
|
+
if (activity) {
|
|
135
|
+
if (platform !== "android" || !/^[A-Za-z0-9_.$]{1,200}$/.test(activity)) throw new Error(`${where}.activity must be an Android activity name such as .MainActivity`)
|
|
136
|
+
app.activity = activity
|
|
137
|
+
}
|
|
138
|
+
const file = text(r.app ?? r.app_file, `${where}.app`)
|
|
139
|
+
if (file) {
|
|
140
|
+
if (/[\x00-\x1f]/.test(file) || !(/^https:\/\//i.test(file) || /^(bs|lt):\/\/[A-Za-z0-9._-]+$/.test(file) || /^storage:filename=[A-Za-z0-9._-]+$/.test(file) || (path.isAbsolute(file) && /\.(apk|aab|ipa|app|zip)$/i.test(file)))) {
|
|
141
|
+
throw new Error(`${where}.app must be an absolute path to an .apk, .aab, .ipa, .app or .zip, an https URL, or a device cloud upload (bs://…)`)
|
|
142
|
+
}
|
|
143
|
+
app.appFile = file
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return app
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseEnvironment(name: string, raw: unknown): Environment {
|
|
150
|
+
const where = `env.yaml environments.${name}`
|
|
151
|
+
if (!NAME.test(name)) throw new Error(`${where}: environment names are letters, digits and _ . -`)
|
|
152
|
+
const r = record(raw, where)
|
|
153
|
+
const apps: Record<string, TestApp> = {}
|
|
154
|
+
for (const [appName, appRaw] of Object.entries(record(r.apps, `${where}.apps`))) apps[appName] = parseApp(appName, appRaw, `${where}.apps.${appName}`)
|
|
155
|
+
const variables: Record<string, string> = {}
|
|
156
|
+
for (const [k, v] of Object.entries(record(r.variables, `${where}.variables`))) {
|
|
157
|
+
if (!VAR_NAME.test(k)) throw new Error(`${where}.variables: "${k}" must be letters, digits and _`)
|
|
158
|
+
const value = text(v, `${where}.variables.${k}`)
|
|
159
|
+
variables[k] = value ?? ""
|
|
160
|
+
}
|
|
161
|
+
const secrets = r.secrets ?? []
|
|
162
|
+
if (!Array.isArray(secrets) || !secrets.every((s) => typeof s === "string" && VAR_NAME.test(s))) {
|
|
163
|
+
throw new Error(`${where}.secrets must be a list of environment variable names (letters, digits and _); env.yaml holds no secret values`)
|
|
164
|
+
}
|
|
165
|
+
const pick = (key: string, kind: AppKind): string | undefined => {
|
|
166
|
+
const named = text(r[key], `${where}.${key}`)
|
|
167
|
+
if (named) {
|
|
168
|
+
if (apps[named]?.kind !== kind) throw new Error(`${where}.${key} must name one of its ${kind} apps`)
|
|
169
|
+
return named
|
|
170
|
+
}
|
|
171
|
+
const ofKind = Object.values(apps).filter((a) => a.kind === kind)
|
|
172
|
+
return ofKind.length === 1 ? ofKind[0]!.name : undefined
|
|
173
|
+
}
|
|
174
|
+
const startApp = pick("start_app", "web")
|
|
175
|
+
const apiApp = pick("api_app", "api")
|
|
176
|
+
return {
|
|
177
|
+
name,
|
|
178
|
+
...(text(r.description, `${where}.description`) ? { description: text(r.description, `${where}.description`) } : {}),
|
|
179
|
+
apps,
|
|
180
|
+
...(startApp ? { startApp } : {}),
|
|
181
|
+
...(apiApp ? { apiApp } : {}),
|
|
182
|
+
variables,
|
|
183
|
+
secrets: [...new Set(secrets as string[])],
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
/** Keys whose values are secrets, in any layout: never shown to a model, never used as values. */
|
|
189
|
+
const SECRET_KEY = /(pass(word)?|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|conn(ection)?[_-]?str(ing)?|sas|cookie|auth)/i
|
|
190
|
+
/** A secret written as a reference to an environment variable: ${NAME}, $NAME, env:NAME, %NAME%, {env:NAME}. */
|
|
191
|
+
const SECRET_REF = /^\s*(?:\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)|env:([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%|\{env:([A-Za-z_][A-Za-z0-9_]*)\})\s*$/
|
|
192
|
+
/** Inline credentials in a value: a connection string's Password=…, a URL's user:password@. */
|
|
193
|
+
const INLINE_SECRET = /(password|pwd|secret|token|accountkey|sharedaccesskey)\s*=\s*[^;]+|\/\/[^/\s:@]+:[^/\s@]+@/i
|
|
194
|
+
const URL_KEYS = /^(url|uri|base[_-]?url|baseurl|host|endpoint|address|link|base|site|web)$/i
|
|
195
|
+
const PATH_KEYS = /^(path|exe|executable|program|binary|app[_-]?path)$/i
|
|
196
|
+
const META_KEYS = new Set(["default", "default_env", "defaultenv", "default_environment", "defaultenvironment", "version", "common", "defaults", "shared", "global", "all"])
|
|
197
|
+
|
|
198
|
+
const safeName = (s: string) => s.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64)
|
|
199
|
+
const safeVar = (s: string) => {
|
|
200
|
+
const v = s.replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "")
|
|
201
|
+
return /^[A-Za-z_]/.test(v) ? v.slice(0, 64) : `v_${v}`.slice(0, 64)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function deepMerge(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown> {
|
|
205
|
+
const out: Record<string, unknown> = { ...a }
|
|
206
|
+
for (const [k, v] of Object.entries(b)) {
|
|
207
|
+
const prev = out[k]
|
|
208
|
+
out[k] = prev && v && typeof prev === "object" && typeof v === "object" && !Array.isArray(prev) && !Array.isArray(v) ? deepMerge(prev as Record<string, unknown>, v as Record<string, unknown>) : v
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The mapping with every secret value replaced, for a model to read. */
|
|
214
|
+
function redact(value: unknown, key = ""): unknown {
|
|
215
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redact(v, k)]))
|
|
216
|
+
if (Array.isArray(value)) return value.map((v) => redact(v, key))
|
|
217
|
+
if (typeof value === "string") {
|
|
218
|
+
if (SECRET_KEY.test(key)) return SECRET_REF.test(value) ? value.trim() : "<secret, not shown>"
|
|
219
|
+
if (INLINE_SECRET.test(value)) return value.replace(INLINE_SECRET, (m) => (m.startsWith("//") ? "//<user>:<secret>@" : `${m.split("=")[0]}=<secret>`))
|
|
220
|
+
}
|
|
221
|
+
return value
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** An environment in the team's own layout: apps, variables and secret names found wherever they are. */
|
|
225
|
+
function parseFreeForm(name: string, r: Record<string, unknown>): Environment {
|
|
226
|
+
const apps: Record<string, TestApp> = {}
|
|
227
|
+
const variables: Record<string, string> = {}
|
|
228
|
+
const secrets = new Set<string>()
|
|
229
|
+
const literal: string[] = []
|
|
230
|
+
let primary: string | undefined
|
|
231
|
+
const addApp = (app: TestApp) => {
|
|
232
|
+
let n = safeName(app.name) || app.kind
|
|
233
|
+
for (let i = 2; apps[n]; i++) n = `${safeName(app.name) || app.kind}${i}`
|
|
234
|
+
apps[n] = { ...app, name: n }
|
|
235
|
+
return n
|
|
236
|
+
}
|
|
237
|
+
const walk = (node: unknown, trail: string[]) => {
|
|
238
|
+
if (node && typeof node === "object" && !Array.isArray(node)) {
|
|
239
|
+
const m = node as Record<string, unknown>
|
|
240
|
+
for (const [k, v] of Object.entries(m)) {
|
|
241
|
+
if (/^(description|desc)$/i.test(k) && trail.length === 0) continue
|
|
242
|
+
const at = [...trail, k]
|
|
243
|
+
if (typeof v === "string") {
|
|
244
|
+
const value = v.trim()
|
|
245
|
+
if (SECRET_KEY.test(k)) {
|
|
246
|
+
const ref = SECRET_REF.exec(value)
|
|
247
|
+
const refName = ref && (ref[1] || ref[2] || ref[3] || ref[4] || ref[5])
|
|
248
|
+
if (refName) secrets.add(refName)
|
|
249
|
+
else if (value) literal.push(at.join("."))
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (INLINE_SECRET.test(value)) {
|
|
253
|
+
literal.push(at.join("."))
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
// The app's name: the key, or for url/path-like keys the mapping it sits in.
|
|
257
|
+
const owner = (keys: RegExp, strip: RegExp) => (keys.test(k) ? trail[trail.length - 1] || "app" : k.replace(strip, "")) || k
|
|
258
|
+
if (/^https?:\/\//i.test(value)) {
|
|
259
|
+
try {
|
|
260
|
+
const url = webUrl(value, at.join("."))
|
|
261
|
+
const appName = owner(URL_KEYS, /[_-]?(base[_-]?)?(url|uri|endpoint|host)$/i)
|
|
262
|
+
const kind: AppKind = /api/i.test(at.join(".")) || /\/api(\/|$)/i.test(new URL(url).pathname) ? "api" : "web"
|
|
263
|
+
const desc = typeof m.description === "string" ? m.description : undefined
|
|
264
|
+
const added = addApp({ name: appName, kind, url, ...(URL_KEYS.test(k) && desc ? { description: desc } : {}) })
|
|
265
|
+
// A URL at the environment's top level (base_url, url) is its main web app: where browser runs start.
|
|
266
|
+
if (trail.length === 0 && URL_KEYS.test(k) && kind === "web" && !primary) primary = added
|
|
267
|
+
} catch {
|
|
268
|
+
// A URL with credentials or another scheme is not an app: it stays out of what the agents can open.
|
|
269
|
+
}
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
if (/^([A-Za-z]:[\\/]|\\\\|\/).*\.exe$/i.test(value) && !/[\x00-\x1f]/.test(value)) {
|
|
273
|
+
const args = Array.isArray(m.args) ? m.args : Array.isArray(m.arguments) ? m.arguments : []
|
|
274
|
+
addApp({ name: owner(PATH_KEYS, /[_-]?(path|exe)$/i), kind: "desktop", path: value, args: args.filter((a) => typeof a === "string" || typeof a === "number").map(String), ...(typeof m.description === "string" ? { description: m.description } : {}) })
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (value.length <= 500 && !/[\r\n]/.test(value)) variables[safeVar(at.join("_"))] = value
|
|
278
|
+
} else if (typeof v === "number" || typeof v === "boolean") {
|
|
279
|
+
if (!SECRET_KEY.test(k)) variables[safeVar(at.join("_"))] = String(v)
|
|
280
|
+
} else if (Array.isArray(v)) {
|
|
281
|
+
if (/^(args|arguments)$/i.test(k)) continue
|
|
282
|
+
if (/^secrets?$/i.test(k)) {
|
|
283
|
+
for (const s of v) {
|
|
284
|
+
if (typeof s !== "string" || !VAR_NAME.test(s)) throw new Error(`env.yaml ${name}: ${[...at].join(".")} must be a list of environment variable names (letters, digits and _); env.yaml holds no secret values`)
|
|
285
|
+
secrets.add(s)
|
|
286
|
+
}
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (!SECRET_KEY.test(k) && v.every((x) => typeof x === "string" || typeof x === "number") && v.length <= 50) variables[safeVar(at.join("_"))] = v.join(", ")
|
|
290
|
+
} else if (v && typeof v === "object") walk(v, at)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
walk(r, [])
|
|
295
|
+
const details = YAML.stringify(redact(r)).trim()
|
|
296
|
+
const env: Environment = {
|
|
297
|
+
name,
|
|
298
|
+
...(typeof r.description === "string" ? { description: r.description } : {}),
|
|
299
|
+
apps,
|
|
300
|
+
variables,
|
|
301
|
+
secrets: [...secrets],
|
|
302
|
+
details: details.length > 6000 ? `${details.slice(0, 6000)}\n# …` : details,
|
|
303
|
+
...(literal.length ? { literalSecrets: literal } : {}),
|
|
304
|
+
}
|
|
305
|
+
const web = Object.values(apps).filter((a) => a.kind === "web")
|
|
306
|
+
const api = Object.values(apps).filter((a) => a.kind === "api")
|
|
307
|
+
if (primary) env.startApp = primary
|
|
308
|
+
else if (web.length === 1) env.startApp = web[0]!.name
|
|
309
|
+
if (api.length === 1) env.apiApp = api[0]!.name
|
|
310
|
+
return env
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** ForgeQE's own layout: apps with a kind each (env.example.yaml). */
|
|
314
|
+
function isForgeqeLayout(r: Record<string, unknown>): boolean {
|
|
315
|
+
const apps = r.apps
|
|
316
|
+
return Boolean(apps && typeof apps === "object" && !Array.isArray(apps) && Object.values(apps).length && Object.values(apps).every((a) => a && typeof a === "object" && "kind" in (a as object)))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function anyEnvironment(name: string, raw: unknown, common: Record<string, unknown>): Environment {
|
|
320
|
+
if (!NAME.test(name)) throw new Error(`env.yaml: "${name}": environment names are letters, digits and _ . -`)
|
|
321
|
+
const r = deepMerge(common, record(raw, `env.yaml ${name}`))
|
|
322
|
+
return isForgeqeLayout(r) ? parseEnvironment(name, r) : parseFreeForm(name, r)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The environments in a document of any layout: `environments:` (or `envs:`), else every top-level mapping that is
|
|
327
|
+
* not a setting (default, common, …). A `common` / `defaults` / `shared` block is merged into each.
|
|
328
|
+
*/
|
|
329
|
+
function environmentsOf(doc: Record<string, unknown>): Record<string, Environment> {
|
|
330
|
+
// A list of environments, each with its name: [{ name: qa, url: … }, …].
|
|
331
|
+
const list = [doc.environments, doc.envs].find((c) => Array.isArray(c)) as unknown[] | undefined
|
|
332
|
+
if (list) {
|
|
333
|
+
const out: Record<string, Environment> = {}
|
|
334
|
+
for (const item of list) {
|
|
335
|
+
const r = record(item, "env.yaml environments[]")
|
|
336
|
+
const name = String(r.name ?? r.env ?? r.id ?? "")
|
|
337
|
+
if (!name) throw new Error("env.yaml: each environment in the list needs a name")
|
|
338
|
+
const { name: _n, env: _e, id: _i, ...rest } = r
|
|
339
|
+
out[name] = anyEnvironment(name, rest, {})
|
|
340
|
+
}
|
|
341
|
+
return out
|
|
342
|
+
}
|
|
343
|
+
const container = [doc.environments, doc.envs, doc.environment].find((c) => c && typeof c === "object" && !Array.isArray(c)) as Record<string, unknown> | undefined
|
|
344
|
+
const from = container || doc
|
|
345
|
+
const commonRaw = [from.common, from.defaults, from.shared, from.global, doc.common, doc.defaults, doc.shared].find((c) => c && typeof c === "object" && !Array.isArray(c)) as Record<string, unknown> | undefined
|
|
346
|
+
const all: Record<string, Environment> = {}
|
|
347
|
+
for (const [name, raw] of Object.entries(from)) {
|
|
348
|
+
if (META_KEYS.has(name.toLowerCase()) || !raw || typeof raw !== "object" || Array.isArray(raw)) continue
|
|
349
|
+
if (!container && ["environments", "envs", "environment"].includes(name)) continue
|
|
350
|
+
all[name] = anyEnvironment(name, raw, commonRaw || {})
|
|
351
|
+
}
|
|
352
|
+
return all
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const defaultOf = (doc: Record<string, unknown>) =>
|
|
356
|
+
[doc.default, doc.default_env, doc.defaultEnv, doc.default_environment, doc.defaultEnvironment].find((v) => typeof v === "string" && v) as string | undefined
|
|
357
|
+
|
|
358
|
+
/** env.yaml's environments, and which is active: `requested` (--env, FORGEQE_ENV), else forgeqe.yaml's default, else env.yaml's. */
|
|
359
|
+
export function loadEnvironments(
|
|
360
|
+
settings: Record<string, unknown>,
|
|
361
|
+
base: string,
|
|
362
|
+
baseDesktop: DesktopConfig,
|
|
363
|
+
requested?: string,
|
|
364
|
+
baseHosts: string[] = [],
|
|
365
|
+
base2: { startUrl?: string; apiUrl?: string; mobile?: MobileConfig } = {},
|
|
366
|
+
): EnvironmentsConfig | undefined {
|
|
367
|
+
const fileSetting = settings.file === undefined ? undefined : String(settings.file || "")
|
|
368
|
+
const dirSetting = settings.dir === undefined ? undefined : String(settings.dir || "")
|
|
369
|
+
if (fileSetting === undefined && dirSetting === undefined && !requested) return undefined
|
|
370
|
+
let file: string
|
|
371
|
+
let all: Record<string, Environment> = {}
|
|
372
|
+
let docDefault: string | undefined
|
|
373
|
+
if (dirSetting !== undefined) {
|
|
374
|
+
// One file per environment (qa.yaml, uat.yaml): the file's name is the environment's.
|
|
375
|
+
file = path.resolve(base, dirSetting || ".")
|
|
376
|
+
if (!existsSync(file)) throw new Error(`environments.dir in forgeqe.yaml names ${file}, which does not exist.`)
|
|
377
|
+
for (const f of readdirSync(file).filter((n) => /\.(ya?ml|json)$/i.test(n)).sort()) {
|
|
378
|
+
const doc = YAML.parse(readFileSync(path.join(file, f), "utf8")) ?? {}
|
|
379
|
+
all[f.replace(/\.(ya?ml|json)$/i, "")] = anyEnvironment(f.replace(/\.(ya?ml|json)$/i, ""), doc, {})
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
file = path.resolve(base, fileSetting || "./env.yaml")
|
|
383
|
+
if (!existsSync(file)) {
|
|
384
|
+
if (fileSetting === undefined) throw new Error(`--env ${requested}: there is no env.yaml here. Add environments: { file: ./env.yaml } to forgeqe.yaml.`)
|
|
385
|
+
throw new Error(`environments.file in forgeqe.yaml names ${file}, which does not exist. Run forgeqe init to create one.`)
|
|
386
|
+
}
|
|
387
|
+
const doc = record(YAML.parse(readFileSync(file, "utf8")) ?? {}, "env.yaml")
|
|
388
|
+
all = environmentsOf(doc)
|
|
389
|
+
docDefault = defaultOf(doc)
|
|
390
|
+
}
|
|
391
|
+
const names = Object.keys(all)
|
|
392
|
+
const fallback = text(settings.default, "environments.default") || docDefault || (names.length === 1 ? names[0] : undefined)
|
|
393
|
+
const active = requested || fallback
|
|
394
|
+
if (active && !all[active]) throw new Error(`No environment "${active}" in ${file} (environments: ${names.join(", ") || "none"}).`)
|
|
395
|
+
return { file, ...(active ? { active } : {}), all, baseDesktop, ...(base2.mobile ? { baseMobile: base2.mobile } : {}), baseHosts, ...(base2.startUrl ? { baseStartUrl: base2.startUrl } : {}), ...(base2.apiUrl ? { baseApiUrl: base2.apiUrl } : {}) }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The desktop apps of an environment, in the form desktop.apps takes (the engine checks each entry again). */
|
|
399
|
+
function desktopApps(env: Environment): Record<string, unknown> {
|
|
400
|
+
const out: Record<string, unknown> = {}
|
|
401
|
+
for (const app of Object.values(env.apps)) {
|
|
402
|
+
if (app.kind === "desktop") out[app.name] = { path: app.path, args: app.args || [], ...(app.window ? { window: app.window } : {}) }
|
|
403
|
+
}
|
|
404
|
+
return out
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The mobile apps of an environment, in the form mobile.apps takes (the engine checks each entry again). */
|
|
408
|
+
function mobileApps(env: Environment): Record<string, unknown> {
|
|
409
|
+
const out: Record<string, unknown> = {}
|
|
410
|
+
for (const app of Object.values(env.apps)) {
|
|
411
|
+
if (app.kind === "mobile") out[app.name] = { platform: app.platform, app_id: app.appId, ...(app.activity ? { activity: app.activity } : {}), ...(app.appFile ? { app: app.appFile } : {}) }
|
|
412
|
+
}
|
|
413
|
+
return out
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The config with `name`'s apps and secrets in effect: its desktop apps join desktop.apps (an environment's entry wins
|
|
418
|
+
* over forgeqe.yaml's of the same name), its mobile apps join mobile.apps, and its secrets join desktop.secrets and
|
|
419
|
+
* mobile.secrets. The config is not changed.
|
|
420
|
+
*/
|
|
421
|
+
export function withEnvironment(cfg: AppConfig, name?: string): AppConfig {
|
|
422
|
+
const envs = cfg.environments
|
|
423
|
+
if (!envs) {
|
|
424
|
+
if (name) throw new Error(`No environments are configured, so "${name}" cannot be used. Add environments: { file: ./env.yaml } to forgeqe.yaml.`)
|
|
425
|
+
return cfg
|
|
426
|
+
}
|
|
427
|
+
const chosen = name || envs.active
|
|
428
|
+
if (!chosen) return cfg
|
|
429
|
+
const env = envs.all[chosen]
|
|
430
|
+
if (!env) throw new Error(`No environment "${chosen}" in ${envs.file} (environments: ${Object.keys(envs.all).join(", ") || "none"}).`)
|
|
431
|
+
const base = envs.baseDesktop
|
|
432
|
+
// An allow-list of hosts (browser.allowed_hosts) also lets in this environment's web apps; an empty one allows any.
|
|
433
|
+
const webHosts = Object.values(env.apps).flatMap((a) => ((a.kind === "web" || a.kind === "api") && a.url ? [new URL(a.url).hostname] : []))
|
|
434
|
+
const hosts = envs.baseHosts.length ? [...new Set([...envs.baseHosts, ...webHosts])] : envs.baseHosts
|
|
435
|
+
// The environment's start app and API app win over forgeqe.yaml's start_url and api.base_url: they are what differs
|
|
436
|
+
// between environments. Without them, forgeqe.yaml's stay.
|
|
437
|
+
const startUrl = env.startApp ? env.apps[env.startApp]!.url : envs.baseStartUrl
|
|
438
|
+
const apiUrl = env.apiApp ? env.apps[env.apiApp]!.url : envs.baseApiUrl
|
|
439
|
+
return {
|
|
440
|
+
...cfg,
|
|
441
|
+
browser: { ...cfg.browser, allowedHosts: hosts, startUrl },
|
|
442
|
+
...(cfg.api ? { api: { ...cfg.api, baseUrl: apiUrl } } : {}),
|
|
443
|
+
environments: { ...envs, active: chosen },
|
|
444
|
+
desktop: { ...base, apps: { ...base.apps, ...desktopApps(env) }, secrets: [...new Set([...base.secrets, ...env.secrets])] },
|
|
445
|
+
...(envs.baseMobile
|
|
446
|
+
? { mobile: { ...envs.baseMobile, apps: { ...envs.baseMobile.apps, ...mobileApps(env) }, secrets: [...new Set([...envs.baseMobile.secrets, ...env.secrets])] } }
|
|
447
|
+
: {}),
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function activeEnvironment(cfg: AppConfig): Environment | undefined {
|
|
452
|
+
const envs = cfg.environments
|
|
453
|
+
return envs?.active ? envs.all[envs.active] : undefined
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** A web app's URL in the active (or named) environment; throws for an unknown app or one that is not a web app. */
|
|
457
|
+
export function webAppUrl(cfg: AppConfig, app: string, env?: string): string {
|
|
458
|
+
const c = withEnvironment(cfg, env)
|
|
459
|
+
const e = activeEnvironment(c)
|
|
460
|
+
const found = e?.apps[app]
|
|
461
|
+
if (!found) throw new Error(`No app "${app}" in ${e ? `environment ${e.name}` : "env.yaml (no environment is active)"}${e ? ` (apps: ${Object.keys(e.apps).join(", ") || "none"})` : ""}.`)
|
|
462
|
+
if (found.kind !== "web" || !found.url) throw new Error(`App "${app}" is a ${found.kind} app, not a web app: use the ${found.kind} tools for it.`)
|
|
463
|
+
return found.url
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** An app name as part of an environment variable name: SHOP for shop, ORDER_API for order-api. */
|
|
467
|
+
const envKey = (name: string) => name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* What tests and other programs ForgeQE runs read the environment from: FORGEQE_ACTIVE_ENV, FORGEQE_APP_<APP>_URL (web
|
|
471
|
+
* and API apps), FORGEQE_APP_<APP>_PATH (desktop apps), FORGEQE_VAR_<NAME> (variables). Never secrets: those stay in
|
|
472
|
+
* the variables env.yaml names.
|
|
473
|
+
*/
|
|
474
|
+
export function environmentVariables(cfg: AppConfig): Record<string, string> {
|
|
475
|
+
const env = activeEnvironment(cfg)
|
|
476
|
+
if (!env) return {}
|
|
477
|
+
const out: Record<string, string> = { FORGEQE_ACTIVE_ENV: env.name }
|
|
478
|
+
for (const a of Object.values(env.apps)) {
|
|
479
|
+
if (a.url) out[`FORGEQE_APP_${envKey(a.name)}_URL`] = a.url
|
|
480
|
+
if (a.path) out[`FORGEQE_APP_${envKey(a.name)}_PATH`] = a.path
|
|
481
|
+
if (a.appId) out[`FORGEQE_APP_${envKey(a.name)}_ID`] = a.appId
|
|
482
|
+
}
|
|
483
|
+
for (const [k, v] of Object.entries(env.variables)) out[`FORGEQE_VAR_${envKey(k)}`] = v
|
|
484
|
+
// A line break would let a value set another variable where these are written line by line (CI env files).
|
|
485
|
+
for (const [k, v] of Object.entries(out)) if (/[\r\n]/.test(v)) delete out[k]
|
|
486
|
+
return out
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const EXPORTED = /^FORGEQE_(ACTIVE_ENV$|APP_|VAR_)/
|
|
490
|
+
|
|
491
|
+
/** Put the active environment's variables into this process's environment, so every test command and agent sees them. */
|
|
492
|
+
export function exportEnvironment(cfg: AppConfig, env: Record<string, string | undefined> = process.env): void {
|
|
493
|
+
for (const k of Object.keys(env)) if (EXPORTED.test(k)) delete env[k]
|
|
494
|
+
Object.assign(env, environmentVariables(cfg))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The environment as one agent needs it. agent: ForgeQE's agent, which picks apps for requests and scenarios. browser:
|
|
499
|
+
* the browser agent, which must stay on this environment's hosts. code: Scribe, the healer and the reviewer, which
|
|
500
|
+
* write and fix tests that must run in every environment.
|
|
501
|
+
*/
|
|
502
|
+
export function environmentBrief(cfg: AppConfig, audience: "agent" | "browser" | "code"): string {
|
|
503
|
+
const env = activeEnvironment(cfg)
|
|
504
|
+
if (!env) return ""
|
|
505
|
+
const apps = Object.values(env.apps)
|
|
506
|
+
const appLine = (a: TestApp) => {
|
|
507
|
+
const where = a.url || a.path || (a.appId ? `${a.platform} ${a.appId}` : "")
|
|
508
|
+
return `- ${a.name} (${a.kind}${where ? `: ${where}` : ""})${a.description ? `: ${a.description}` : ""}`
|
|
509
|
+
}
|
|
510
|
+
const values = Object.entries(env.variables)
|
|
511
|
+
if (audience === "browser") {
|
|
512
|
+
const web = apps.filter((a) => a.kind === "web" || a.kind === "api")
|
|
513
|
+
if (!web.length && !values.length) return ""
|
|
514
|
+
return [
|
|
515
|
+
`Test environment: ${env.name}${env.description ? ` (${env.description})` : ""}.`,
|
|
516
|
+
...(web.length ? ["Its web apps, where a goal that names one of them means:", ...web.map(appLine), "Stay on this environment's hosts; never use another environment's URL for these apps."] : []),
|
|
517
|
+
...(values.length ? ["Values of this environment, for goals that refer to them:", ...values.map(([k, v]) => `- ${k}: ${v}`)] : []),
|
|
518
|
+
].join("\n")
|
|
519
|
+
}
|
|
520
|
+
if (audience === "code") {
|
|
521
|
+
const vars = environmentVariables(cfg)
|
|
522
|
+
const urls = Object.entries(vars).filter(([k]) => /_URL$/.test(k))
|
|
523
|
+
return [
|
|
524
|
+
`The tests run against test environment ${env.name}; others are ${Object.keys(cfg.environments!.all).filter((n) => n !== env.name).join(", ") || "none yet"}. A test must run unchanged in every environment:`,
|
|
525
|
+
"- Never write an environment's URL, host or app path into a test. Read it where the repository already keeps its base URL or settings; if it keeps none, read these variables, which ForgeQE sets whenever it runs the tests, with the repository's own default as fallback:",
|
|
526
|
+
...urls.map(([k, v]) => ` ${k} (now ${v})`),
|
|
527
|
+
"- FORGEQE_ACTIVE_ENV names the environment, and FORGEQE_VAR_<NAME> holds its values (" + (values.map(([k]) => k).join(", ") || "none") + "). Secrets are never in these variables.",
|
|
528
|
+
].join("\n")
|
|
529
|
+
}
|
|
530
|
+
const others = Object.values(cfg.environments!.all).filter((e) => e.name !== env.name)
|
|
531
|
+
return [
|
|
532
|
+
`Test environment in use: ${env.name}${env.description ? ` (${env.description})` : ""}, from ${cfg.environments!.file}.${others.length ? ` Others: ${others.map((e) => e.name).join(", ")} (use_environment switches; tools also take env for one call).` : ""}`,
|
|
533
|
+
apps.length ? ["Its apps (pick by description; never invent a URL or path):", ...apps.map(appLine)].join("\n") : "It has no apps yet.",
|
|
534
|
+
env.details
|
|
535
|
+
? `The environment as the team wrote it (secrets replaced); use its names and values when a request refers to them:\n\`\`\`yaml\n${env.details}\n\`\`\``
|
|
536
|
+
: values.length
|
|
537
|
+
? `Its values: ${values.map(([k, v]) => `${k}=${v}`).join(", ")}.`
|
|
538
|
+
: "",
|
|
539
|
+
env.secrets.length ? `Secrets its workflows may use, by name only: ${env.secrets.join(", ")}.` : "",
|
|
540
|
+
env.literalSecrets?.length ? `Secret values are written in the file at ${env.literalSecrets.join(", ")}: they are not used or shown; tell the user to move them to environment variables.` : "",
|
|
541
|
+
env.startApp ? `Browser runs start at ${env.startApp} when no app or URL is given.` : "",
|
|
542
|
+
]
|
|
543
|
+
.filter(Boolean)
|
|
544
|
+
.join("\n")
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** `forgeqe apps`: the environments, and the apps of the active (or named) one. */
|
|
548
|
+
export function appsText(cfg: AppConfig, env?: string): string {
|
|
549
|
+
if (!cfg.environments) return "No environments are configured. Run forgeqe init (it writes env.yaml), or add environments: { file: ./env.yaml } to forgeqe.yaml."
|
|
550
|
+
const c = withEnvironment(cfg, env)
|
|
551
|
+
const active = activeEnvironment(c)
|
|
552
|
+
const lines: string[] = [`environments (${c.environments!.file})`]
|
|
553
|
+
for (const e of Object.values(c.environments!.all)) lines.push(`${e.name === active?.name ? "*" : " "} ${e.name.padEnd(12)} ${e.description || ""}`.trimEnd())
|
|
554
|
+
if (!active) return [...lines, "", "No environment is active: pass --env NAME, set FORGEQE_ENV, or set default in env.yaml."].join("\n")
|
|
555
|
+
lines.push("", `apps in ${active.name}`)
|
|
556
|
+
for (const a of Object.values(active.apps)) {
|
|
557
|
+
const where = a.url ? a.url : a.path ? `${a.path}${a.args?.length ? ` ${JSON.stringify(a.args)}` : ""}` : `${a.platform} ${a.appId}`
|
|
558
|
+
lines.push(` ${a.name.padEnd(14)} ${a.kind.padEnd(8)} ${where}${a.description ? `\n ${"".padEnd(14)} ${"".padEnd(8)} ${a.description}` : ""}`)
|
|
559
|
+
}
|
|
560
|
+
if (!Object.keys(active.apps).length) lines.push(" (none)")
|
|
561
|
+
const vars = Object.entries(active.variables)
|
|
562
|
+
if (vars.length) lines.push("", "variables", ...vars.map(([k, v]) => ` ${k} = ${v}`))
|
|
563
|
+
if (active.literalSecrets?.length) lines.push("", `! secret values written in the file (not used; move them to environment variables): ${active.literalSecrets.join(", ")}`)
|
|
564
|
+
lines.push("", `secrets ${c.desktop.secrets.join(", ") || "none"}`)
|
|
565
|
+
return lines.join("\n")
|
|
566
|
+
}
|
package/src/evals/domain.ts
CHANGED
|
@@ -238,6 +238,7 @@ export async function runDomainEval(opts: EvalOptions): Promise<EvalReport> {
|
|
|
238
238
|
profile: judgeConfig.llm,
|
|
239
239
|
// The judge is given the domain as data and must not apply the domain rule itself.
|
|
240
240
|
domain: null,
|
|
241
|
+
guardrails: null,
|
|
241
242
|
role: "judge",
|
|
242
243
|
model: judgeConfig.code.model,
|
|
243
244
|
bin: judgeConfig.code.opencode,
|
package/src/explore/charters.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Story } from "./story.ts"
|
|
|
6
6
|
import { domainNotes } from "../domain.ts"
|
|
7
7
|
import { knowledgeBlock, readKnowledge, relevantKnowledge } from "../knowledge.ts"
|
|
8
8
|
import { prompt } from "../prompts.ts"
|
|
9
|
+
import { guardrailsNotes } from "../guardrails.ts"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Exploratory charters: what to try on a story's feature beyond its scripted test cases. Each charter has a mission (the
|
|
@@ -113,6 +114,7 @@ export async function planCharters(opts: {
|
|
|
113
114
|
repo: opts.cwd,
|
|
114
115
|
profile: opts.config.llm,
|
|
115
116
|
domain: domainNotes(opts.config),
|
|
117
|
+
guardrails: guardrailsNotes(opts.config),
|
|
116
118
|
model: opts.config.code.model,
|
|
117
119
|
bin: opts.config.code.opencode,
|
|
118
120
|
readDirs: [opts.cwd],
|