@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
package/src/code/loop.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { CODE_GRAPH_SERVER, codeGraphTools } from "./graph/tools.ts"
|
|
|
15
15
|
import { localToolServer } from "../mcp/server.ts"
|
|
16
16
|
import type { ToolContext } from "../mcp/tools.ts"
|
|
17
17
|
import { addWrittenTest, notesSection, readNotes, saveNotes } from "./notes.ts"
|
|
18
|
+
import { environmentBrief } from "../environments.ts"
|
|
18
19
|
import { canReplay, replayRun, savedReplay, type Replay } from "./replay.ts"
|
|
19
20
|
import { openSession, READ_AGENT, TEST_AGENT, type OpencodeSession, type PromptOutcome } from "./runtime.ts"
|
|
20
21
|
import { failureContext } from "./slices.ts"
|
|
@@ -48,6 +49,7 @@ import { runTestCommand } from "./testrun.ts"
|
|
|
48
49
|
import { changedFiles, jobChanges, ownPaths, preexisting, protectPatterns, restoreRemoved, restoreSnapshot, snapshot, snapshotFiles } from "./worktree.ts"
|
|
49
50
|
import { loadSkills } from "../skills/registry.ts"
|
|
50
51
|
import { domainNotes } from "../domain.ts"
|
|
52
|
+
import { guardrailsNotes } from "../guardrails.ts"
|
|
51
53
|
|
|
52
54
|
function realpathOr(dir: string): string {
|
|
53
55
|
try {
|
|
@@ -281,6 +283,7 @@ export async function runCodeAgent(opts: {
|
|
|
281
283
|
repo,
|
|
282
284
|
profile: config.llm,
|
|
283
285
|
domain: domainNotes(config),
|
|
286
|
+
guardrails: guardrailsNotes(config),
|
|
284
287
|
model: config.code.model,
|
|
285
288
|
bin: config.code.opencode,
|
|
286
289
|
readDirs,
|
|
@@ -379,7 +382,7 @@ export async function runCodeAgent(opts: {
|
|
|
379
382
|
let prompt =
|
|
380
383
|
resumed && earlier?.stage === "writing"
|
|
381
384
|
? carryOnPrompt(liveOn)
|
|
382
|
-
: `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}), ...(opts.target ? { target: opts.target } : {}) })}`
|
|
385
|
+
: `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}), ...(opts.target ? { target: opts.target } : {}), ...(environmentBrief(config, "code") ? { environment: environmentBrief(config, "code") } : {}) })}`
|
|
383
386
|
if (prepared) {
|
|
384
387
|
stage = "writing"
|
|
385
388
|
saveSession()
|
package/src/code/prompt.ts
CHANGED
|
@@ -154,7 +154,7 @@ function existingSection(e: ExistingTest): string[] {
|
|
|
154
154
|
]
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest; target?: TestTarget } = {}): string {
|
|
157
|
+
export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest; target?: TestTarget; environment?: string } = {}): string {
|
|
158
158
|
const live = opts.liveBrowserDir
|
|
159
159
|
? opts.liveOn
|
|
160
160
|
? `Your live_browser_* tools are on because the replay failed at a step; use them on that step's page. They save snapshots and screenshots in ${opts.liveBrowserDir}.`
|
|
@@ -176,6 +176,7 @@ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; l
|
|
|
176
176
|
...(opts.existing ? existingSection(opts.existing) : opts.target ? targetSection(opts.target) : []),
|
|
177
177
|
...(opts.profile ? [opts.profile, ""] : []),
|
|
178
178
|
...(opts.notes ? [opts.notes, ""] : []),
|
|
179
|
+
...(opts.environment ? [opts.environment, ""] : []),
|
|
179
180
|
handoff.markdown,
|
|
180
181
|
"",
|
|
181
182
|
`(The same handoff is saved at ${handoff.file}; you do not need to read it again.)`,
|
package/src/code/prreview.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Price } from "../usage.ts"
|
|
|
15
15
|
import { openSession, type AgentSpec, type OpencodeSession } from "./runtime.ts"
|
|
16
16
|
import { prompt } from "../prompts.ts"
|
|
17
17
|
import { domainNotes } from "../domain.ts"
|
|
18
|
+
import { guardrailsNotes } from "../guardrails.ts"
|
|
18
19
|
|
|
19
20
|
const API = "api-version=7.1"
|
|
20
21
|
/** The diff is shown in the prompt up to this size; the reviewer runs git diff for the rest. */
|
|
@@ -469,6 +470,7 @@ export async function reviewPullRequest(opts: {
|
|
|
469
470
|
repo: co.tree,
|
|
470
471
|
profile,
|
|
471
472
|
domain: domainNotes(opts.config),
|
|
473
|
+
guardrails: guardrailsNotes(opts.config),
|
|
472
474
|
model: profile === opts.config.llm ? opts.config.code.model : undefined,
|
|
473
475
|
bin: opts.config.code.opencode,
|
|
474
476
|
readDirs: [co.tree],
|
package/src/code/review.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path"
|
|
|
3
3
|
|
|
4
4
|
import type { CodeEvent, CodeReview, Usage } from "../types.ts"
|
|
5
5
|
import { privateEnvNames } from "../childEnv.ts"
|
|
6
|
+
import { environmentBrief } from "../environments.ts"
|
|
6
7
|
import type { AppConfig } from "../config.ts"
|
|
7
8
|
import type { Handoff } from "./handoff.ts"
|
|
8
9
|
import { openSession, type AgentSpec, type OpencodeSession } from "./runtime.ts"
|
|
@@ -12,6 +13,7 @@ import type { Price } from "../usage.ts"
|
|
|
12
13
|
import { prompt } from "../prompts.ts"
|
|
13
14
|
import { domainNotes } from "../domain.ts"
|
|
14
15
|
import type { TestTarget } from "./prompt.ts"
|
|
16
|
+
import { guardrailsNotes } from "../guardrails.ts"
|
|
15
17
|
|
|
16
18
|
const OUTPUT_CHARS = 4_000
|
|
17
19
|
/** Changed files are shown in the prompt up to these sizes; the reviewer reads the rest itself. */
|
|
@@ -118,7 +120,7 @@ export function changedFiles(repo: string, paths: string[]): string[] {
|
|
|
118
120
|
return lines
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
export function reviewPrompt(input: ReviewInput, repo?: string): string {
|
|
123
|
+
export function reviewPrompt(input: ReviewInput, repo?: string, environment?: string): string {
|
|
122
124
|
const { handoff, report } = input
|
|
123
125
|
const out = input.output.length > OUTPUT_CHARS ? `…${input.output.slice(-OUTPUT_CHARS)}` : input.output
|
|
124
126
|
const changed = input.changes || report.changed || []
|
|
@@ -127,6 +129,7 @@ export function reviewPrompt(input: ReviewInput, repo?: string): string {
|
|
|
127
129
|
`Review the test the coding agent wrote for this goal: ${handoff.goal}`,
|
|
128
130
|
`The handoff it worked from: ${handoff.file}`,
|
|
129
131
|
...(checks ? ["", "The browser agent's checks, from the handoff:", checks] : []),
|
|
132
|
+
...(environment ? ["", environment, "An environment's URL, host or path written into the test, where these settings or the repository's own should be read, is a correctness issue."] : []),
|
|
130
133
|
"",
|
|
131
134
|
...(input.target
|
|
132
135
|
? [
|
|
@@ -207,6 +210,7 @@ export function openReviewer(opts: {
|
|
|
207
210
|
repo: opts.repo,
|
|
208
211
|
profile: reviewProfile,
|
|
209
212
|
domain: domainNotes(opts.config),
|
|
213
|
+
guardrails: guardrailsNotes(opts.config),
|
|
210
214
|
model: reviewProfile === opts.config.llm ? opts.config.code.model : undefined,
|
|
211
215
|
bin: opts.config.code.opencode,
|
|
212
216
|
readDirs: opts.readDirs,
|
|
@@ -224,7 +228,7 @@ export function openReviewer(opts: {
|
|
|
224
228
|
...(opts.mcp ? { mcp: opts.mcp } : {}),
|
|
225
229
|
})
|
|
226
230
|
}
|
|
227
|
-
let reply = await session.prompt(reviewPrompt(input, opts.repo))
|
|
231
|
+
let reply = await session.prompt(reviewPrompt(input, opts.repo, environmentBrief(opts.config, "code") || undefined))
|
|
228
232
|
if (!reply.report && !reply.error) reply = await session.prompt("Give the structured result: approved, summary, and issues.")
|
|
229
233
|
Object.assign(usage, session.usage)
|
|
230
234
|
const r = reply.report
|
package/src/code/runtime.ts
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path"
|
|
|
5
5
|
|
|
6
6
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
7
7
|
|
|
8
|
+
import { COPILOT_PROVIDER_ID, copilotProfileError, RUNTIME_AUTH_ENV, runtimeAuthContent } from "../copilot.ts"
|
|
8
9
|
import { entraGateway } from "../entra.ts"
|
|
9
10
|
import { missingKey, type LlmProfile } from "../llm.ts"
|
|
10
11
|
import type { CodeEvent, FileAction, PlanItem, Usage } from "../types.ts"
|
|
@@ -122,11 +123,27 @@ export function providerFor(profile: LlmProfile): { provider: Record<string, unk
|
|
|
122
123
|
},
|
|
123
124
|
model: { providerID: "forgeqe", modelID: profile.model },
|
|
124
125
|
}
|
|
126
|
+
case "github_copilot": {
|
|
127
|
+
const bad = copilotProfileError(profile)
|
|
128
|
+
if (bad) throw new Error(bad)
|
|
129
|
+
// The agent runtime's own GitHub Copilot provider; its sign-in comes in RUNTIME_AUTH_ENV (copilotSecrets).
|
|
130
|
+
return { provider: {}, model: { providerID: COPILOT_PROVIDER_ID, modelID: profile.model } }
|
|
131
|
+
}
|
|
125
132
|
default:
|
|
126
133
|
throw new Error(`The coding agent needs a live LLM profile; "${profile.provider}" has no model. Pick one with --profile or set code.model.`)
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
/**
|
|
138
|
+
* For a Copilot model, the saved Copilot sign-in as the agent runtime reads it from its environment, so it never sits
|
|
139
|
+
* in the runtime's auth.json where the agent's shell could read it. Without a saved one the runtime uses its own sign-in.
|
|
140
|
+
*/
|
|
141
|
+
export function copilotSecrets(model: ModelRef, saved?: string): Record<string, string> {
|
|
142
|
+
if (model.providerID !== COPILOT_PROVIDER_ID) return {}
|
|
143
|
+
const content = runtimeAuthContent(saved)
|
|
144
|
+
return content ? { [RUNTIME_AUTH_ENV]: content } : {}
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
function parseModel(ref: string): ModelRef {
|
|
131
148
|
const i = ref.indexOf("/")
|
|
132
149
|
if (i <= 0) throw new Error(`code.model must be provider/model, got "${ref}".`)
|
|
@@ -266,6 +283,9 @@ export const BASH_PERMISSION = {
|
|
|
266
283
|
"*git -c * push*": "deny",
|
|
267
284
|
"gh repo delete*": "deny",
|
|
268
285
|
"*gh repo delete*": "deny",
|
|
286
|
+
// Containers start through ForgeQE's services and mock_service tools, which run only what forgeqe.yaml declares: a
|
|
287
|
+
// container command from the agent could mount the whole disk or run privileged (docs/CONTAINERS.md).
|
|
288
|
+
...Object.fromEntries(["docker", "podman", "nerdctl", "docker-compose", "podman-compose"].flatMap((c) => [[`${c} *`, "deny"], [`${c}`, "deny"], [`* ${c} *`, "deny"], [`*/${c} *`, "deny"], [`*${c}.exe*`, "deny"]])),
|
|
269
289
|
}
|
|
270
290
|
|
|
271
291
|
/**
|
|
@@ -524,6 +544,11 @@ export function withDomain(agent: AgentSpec, domain: string | null): AgentSpec {
|
|
|
524
544
|
return domain && !prompt.includes(domain) ? { ...agent, prompt: `${prompt}\n\n${domain}` } : agent
|
|
525
545
|
}
|
|
526
546
|
|
|
547
|
+
/** The agent with the team's guardrails added to its instructions (once), last, so nothing after them overrides them. */
|
|
548
|
+
export function withGuardrails(agent: AgentSpec, guardrails: string | null): AgentSpec {
|
|
549
|
+
return guardrails && !agent.prompt.includes(guardrails) ? { ...agent, prompt: `${agent.prompt}\n\n${guardrails}` } : agent
|
|
550
|
+
}
|
|
551
|
+
|
|
527
552
|
export async function openSession<R = AgentReport>(opts: {
|
|
528
553
|
repo: string
|
|
529
554
|
profile: LlmProfile
|
|
@@ -561,12 +586,17 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
561
586
|
* not apply (a judge that is given the domain as data).
|
|
562
587
|
*/
|
|
563
588
|
domain: string | null
|
|
589
|
+
/**
|
|
590
|
+
* The team's guardrails (guardrailsNotes: the rules in .forgeqe/guardrails), added to every agent's instructions.
|
|
591
|
+
* Required for the same reason as domain; null when the team has set none, or for a judge scoring other agents.
|
|
592
|
+
*/
|
|
593
|
+
guardrails: string | null
|
|
564
594
|
/** Values for {env:} references in `mcp` that are not in this terminal (e.g. base64 of email:token). */
|
|
565
595
|
secrets?: Record<string, string>
|
|
566
596
|
/** Who this session is in the usage meter. Default Scribe. */
|
|
567
597
|
role?: Role
|
|
568
598
|
}): Promise<OpencodeSession<R>> {
|
|
569
|
-
const missing = opts.model ? null : missingKey(opts.profile)
|
|
599
|
+
const missing = opts.model ? null : missingKey(opts.profile, "agent")
|
|
570
600
|
if (missing) throw new Error(missing)
|
|
571
601
|
const bin = findOpencode(opts.bin)
|
|
572
602
|
if (!bin) throw new Error(`The agent runtime was not found. ${INSTALL_HINT}`)
|
|
@@ -594,8 +624,8 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
594
624
|
profile: gateway?.profile || opts.profile,
|
|
595
625
|
model: opts.model,
|
|
596
626
|
readDirs,
|
|
597
|
-
agent: withDomain(agent, opts.domain),
|
|
598
|
-
agents: opts.agents?.map((a) => withDomain(a, opts.domain)),
|
|
627
|
+
agent: withGuardrails(withDomain(agent, opts.domain), opts.guardrails),
|
|
628
|
+
agents: opts.agents?.map((a) => withGuardrails(withDomain(a, opts.domain), opts.guardrails)),
|
|
599
629
|
mcp: opts.mcp,
|
|
600
630
|
protect,
|
|
601
631
|
readOnly: skillPaths,
|
|
@@ -607,6 +637,7 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
607
637
|
throw err
|
|
608
638
|
}
|
|
609
639
|
const { config, model } = built
|
|
640
|
+
Object.assign(secrets, copilotSecrets(model))
|
|
610
641
|
const pluginDir = mkdtempSync(path.join(os.tmpdir(), "forgeqe-runtime-"))
|
|
611
642
|
const shellHidden = [...(opts.hide || []), ...Object.keys(secrets), ...envRefs(config), "OPENCODE_CONFIG_CONTENT"]
|
|
612
643
|
config.plugin = [...((config.plugin as string[] | undefined) || []), hideEnvPlugin(pluginDir, shellHidden)]
|
package/src/commands.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type ShellCommand =
|
|
|
7
7
|
/** value unset: toggle. */
|
|
8
8
|
| { type: "details"; value?: boolean }
|
|
9
9
|
| { type: "profile"; name: string }
|
|
10
|
+
/** name unset: list the test environments; else switch to that one (env.yaml). */
|
|
11
|
+
| { type: "env"; name?: string }
|
|
10
12
|
| { type: "logs"; shots: boolean }
|
|
11
13
|
| { type: "skills"; name?: string }
|
|
12
14
|
| { type: "features" }
|
|
@@ -36,6 +38,7 @@ export const HELP = [
|
|
|
36
38
|
" /headed on|off show the browser (live profiles)",
|
|
37
39
|
" /details [on|off] show everything the agents do (reasoning, every file read, engine logs); off: just progress",
|
|
38
40
|
" /profile <name> switch LLM profile (or press tab)",
|
|
41
|
+
" /env [name] the test environments in env.yaml and their apps, or switch to one",
|
|
39
42
|
" /logs [shots] save this session's log and runs in one archive to share (shots: with screenshots)",
|
|
40
43
|
" /features what ForgeQE can do, and what each capability still needs here",
|
|
41
44
|
" /init set ForgeQE up in this folder: forgeqe.yaml, llms.json, editable prompts, skills, knowledge",
|
|
@@ -49,7 +52,7 @@ export const HELP = [
|
|
|
49
52
|
"Select text with the mouse to copy it.",
|
|
50
53
|
].join("\n")
|
|
51
54
|
|
|
52
|
-
const COMMANDS = new Set(["help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init", "rerun"])
|
|
55
|
+
const COMMANDS = new Set(["env", "help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init", "rerun"])
|
|
53
56
|
|
|
54
57
|
/** Session controls; every other line, including one that starts with a path, goes to the agent as text. */
|
|
55
58
|
export function parseCommand(raw: string): ShellCommand {
|
|
@@ -81,6 +84,8 @@ export function parseCommand(raw: string): ShellCommand {
|
|
|
81
84
|
return arg ? { type: "skills", name: arg } : { type: "skills" }
|
|
82
85
|
case "features":
|
|
83
86
|
return { type: "features" }
|
|
87
|
+
case "env":
|
|
88
|
+
return arg ? { type: "env", name: arg } : { type: "env" }
|
|
84
89
|
case "init":
|
|
85
90
|
return { type: "init" }
|
|
86
91
|
case "rerun":
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setPromptDirs } from "./prompts.ts"
|
|
2
2
|
import { qualityConfig, type QualityConfig } from "./quality/budgets.ts"
|
|
3
|
+
import { containersConfig, type ContainersConfig } from "./containers/config.ts"
|
|
3
4
|
import { existsSync, readFileSync } from "node:fs"
|
|
4
5
|
import os from "node:os"
|
|
5
6
|
import path from "node:path"
|
|
@@ -11,6 +12,8 @@ import YAML from "yaml"
|
|
|
11
12
|
import type { LlmProfile } from "./llm.ts"
|
|
12
13
|
import type { ReplayMode } from "./types.ts"
|
|
13
14
|
import { loadDotEnv } from "./env.ts"
|
|
15
|
+
import { CAPABILITIES } from "./capabilities/registry.ts"
|
|
16
|
+
import { exportEnvironment, loadEnvironments, withEnvironment, type EnvironmentsConfig } from "./environments.ts"
|
|
14
17
|
import { systemLocale } from "./locale.ts"
|
|
15
18
|
import { currencyCode, currencyForLocale } from "./money.ts"
|
|
16
19
|
import { listPrice, pricesDate, type DeploymentType, type ListPrice } from "./prices.ts"
|
|
@@ -366,13 +369,28 @@ export type AppConfig = {
|
|
|
366
369
|
profiles: Record<string, LlmProfile>
|
|
367
370
|
llm: LlmProfile
|
|
368
371
|
browser: BrowserConfig
|
|
372
|
+
/** `desktop:` in forgeqe.yaml: the Windows apps desktop workflows may start, and the secrets they may type (docs/DESKTOP.md). */
|
|
373
|
+
desktop: DesktopConfig
|
|
369
374
|
/**
|
|
370
|
-
*
|
|
375
|
+
* `agent.capabilities` in forgeqe.yaml: capabilities whose tools ForgeQE's agent always has (always), and ones it never
|
|
376
|
+
* gets (off). The rest it gets per request (src/mcp/scope.ts).
|
|
377
|
+
*/
|
|
378
|
+
agentScope: { always: string[]; off: string[] }
|
|
379
|
+
/** `mobile:` in forgeqe.yaml: Android and iOS apps, Appium or Maestro, and the secrets workflows may type (docs/MOBILE.md). */
|
|
380
|
+
mobile: MobileConfig
|
|
381
|
+
/** `containers:` in forgeqe.yaml: services ForgeQE may start in Docker or Podman, and the mocks' image (docs/CONTAINERS.md). */
|
|
382
|
+
containers?: ContainersConfig
|
|
383
|
+
/** Test environments from env.yaml (`environments:` in forgeqe.yaml); the active one's desktop and mobile apps are in `desktop` and `mobile`. */
|
|
384
|
+
environments?: EnvironmentsConfig
|
|
385
|
+
/**
|
|
386
|
+
* stepInstruction is added to the browser agent's prompt; guiderails' lines are guardrails for every agent (src/guardrails.ts). dirs: where edited prompts are looked for,
|
|
371
387
|
* highest precedence first (prompts.dir, the project's .forgeqe/prompts, ~/.forgeqe/prompts); see src/prompts.ts.
|
|
372
388
|
*/
|
|
373
389
|
prompts: { stepInstruction: string; guiderails: string; dirs?: { dir: string; source: string }[] }
|
|
374
390
|
/** Product knowledge (.forgeqe/knowledge): see src/knowledge.ts. */
|
|
375
391
|
knowledge?: { dir: string; browserMaxChars: number }
|
|
392
|
+
/** The team's guardrails (.forgeqe/guardrails): see src/guardrails.ts. */
|
|
393
|
+
guardrails?: { dir: string }
|
|
376
394
|
/** Defect intelligence (defects: in forgeqe.yaml): see src/cases/defects.ts, repro.ts, rca.ts. */
|
|
377
395
|
defects: DefectsConfig
|
|
378
396
|
export: { outDir: string; format: string }
|
|
@@ -412,6 +430,122 @@ export type AppConfig = {
|
|
|
412
430
|
budget?: BudgetConfig
|
|
413
431
|
}
|
|
414
432
|
|
|
433
|
+
export type DesktopConfig = {
|
|
434
|
+
/** App name to { path, args, window }; the desktop engine checks each entry (an absolute .exe, an argument list). */
|
|
435
|
+
apps: Record<string, unknown>
|
|
436
|
+
/** Environment variables a workflow's {{secret:NAME}} may read; nothing else in the environment reaches the engine. */
|
|
437
|
+
secrets: string[]
|
|
438
|
+
/** Replay asks the model for a new locator when a step's control is not found (desktop.heal, default on). */
|
|
439
|
+
heal: boolean
|
|
440
|
+
screenshots: boolean
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
|
|
444
|
+
|
|
445
|
+
function desktopConfig(raw: Record<string, unknown>): DesktopConfig {
|
|
446
|
+
const secrets = raw.secrets === undefined || raw.secrets === null ? [] : raw.secrets
|
|
447
|
+
if (!Array.isArray(secrets) || !secrets.every((n) => typeof n === "string" && ENV_NAME.test(n))) {
|
|
448
|
+
throw new Error("desktop.secrets in forgeqe.yaml must be a list of environment variable names (letters, digits and _).")
|
|
449
|
+
}
|
|
450
|
+
return { apps: asRecord(raw.apps), secrets: [...new Set(secrets as string[])], heal: raw.heal !== false, screenshots: raw.screenshots !== false }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export type MobileConfig = {
|
|
454
|
+
/** App name to { platform, app_id, activity, app }; the mobile engine checks each entry. */
|
|
455
|
+
apps: Record<string, unknown>
|
|
456
|
+
/** Environment variables a workflow's {{secret:NAME}} may read. */
|
|
457
|
+
secrets: string[]
|
|
458
|
+
/** How workflows replay unless a run says otherwise: appium (step by step, with healing) or maestro. */
|
|
459
|
+
driver: "appium" | "maestro"
|
|
460
|
+
appium: {
|
|
461
|
+
/** The Appium server: the team's own (default http://127.0.0.1:4723) or a device cloud's hub. */
|
|
462
|
+
url: string
|
|
463
|
+
/** Extra W3C capabilities (appium:deviceName, appium:udid, appium:platformVersion, a cloud's options). */
|
|
464
|
+
capabilities: Record<string, unknown>
|
|
465
|
+
/** A device cloud's user and key, as the names of environment variables. */
|
|
466
|
+
usernameEnv?: string
|
|
467
|
+
accessKeyEnv?: string
|
|
468
|
+
}
|
|
469
|
+
maestro: {
|
|
470
|
+
command: string[]
|
|
471
|
+
device?: string
|
|
472
|
+
/** Maestro on TestMu AI's devices through HyperExecute, instead of a local maestro. */
|
|
473
|
+
testmu?: { cli: string; devices: string[]; realDevice: boolean; region?: string; usernameEnv: string; accessKeyEnv: string }
|
|
474
|
+
}
|
|
475
|
+
heal: boolean
|
|
476
|
+
screenshots: boolean
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function agentScopeConfig(raw: Record<string, unknown>): AppConfig["agentScope"] {
|
|
480
|
+
const caps = asRecord(raw.capabilities)
|
|
481
|
+
const known = new Set(CAPABILITIES.map((c) => c.id))
|
|
482
|
+
const list = (key: "always" | "off") => {
|
|
483
|
+
const v = caps[key]
|
|
484
|
+
if (v === undefined || v === null) return []
|
|
485
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === "string" && known.has(x))) {
|
|
486
|
+
throw new Error(`agent.capabilities.${key} in forgeqe.yaml must list capability ids (${[...known].join(", ")}).`)
|
|
487
|
+
}
|
|
488
|
+
return [...new Set(v as string[])]
|
|
489
|
+
}
|
|
490
|
+
const always = list("always")
|
|
491
|
+
const off = list("off")
|
|
492
|
+
const both = always.filter((c) => off.includes(c))
|
|
493
|
+
if (both.length) throw new Error(`agent.capabilities: ${both.join(", ")} cannot be both always and off.`)
|
|
494
|
+
return { always, off }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function mobileConfig(raw: Record<string, unknown>, base: string = process.cwd()): MobileConfig {
|
|
498
|
+
const secrets = raw.secrets === undefined || raw.secrets === null ? [] : raw.secrets
|
|
499
|
+
if (!Array.isArray(secrets) || !secrets.every((n) => typeof n === "string" && ENV_NAME.test(n))) {
|
|
500
|
+
throw new Error("mobile.secrets in forgeqe.yaml must be a list of environment variable names (letters, digits and _).")
|
|
501
|
+
}
|
|
502
|
+
const driver = raw.driver === undefined ? "appium" : raw.driver
|
|
503
|
+
if (driver !== "appium" && driver !== "maestro") throw new Error("mobile.driver in forgeqe.yaml must be appium or maestro.")
|
|
504
|
+
const a = asRecord(raw.appium)
|
|
505
|
+
const url = a.url === undefined ? "http://127.0.0.1:4723" : String(a.url)
|
|
506
|
+
if (!/^https?:\/\/[^\s]+$/i.test(url)) throw new Error("mobile.appium.url in forgeqe.yaml must be an http(s) URL.")
|
|
507
|
+
if (/^https?:\/\/[^/]*@/i.test(url)) throw new Error("mobile.appium.url must not hold a user or key: use mobile.appium.username_env and access_key_env.")
|
|
508
|
+
const envName = (v: unknown, key: string): string | undefined => {
|
|
509
|
+
if (v === undefined || v === null || v === "") return undefined
|
|
510
|
+
if (typeof v !== "string" || !ENV_NAME.test(v)) throw new Error(`mobile.appium.${key} must be the name of an environment variable.`)
|
|
511
|
+
return v
|
|
512
|
+
}
|
|
513
|
+
const m = asRecord(raw.maestro)
|
|
514
|
+
const command = m.command === undefined ? ["maestro"] : Array.isArray(m.command) ? m.command.map(String) : [String(m.command)]
|
|
515
|
+
if (!command.length || command.some((c) => !c || /[\r\n]/.test(c))) throw new Error("mobile.maestro.command must be the maestro program (and its arguments) as a list.")
|
|
516
|
+
const device = m.device === undefined ? undefined : String(m.device)
|
|
517
|
+
if (device !== undefined && !/^[A-Za-z0-9._:-]{1,100}$/.test(device)) throw new Error("mobile.maestro.device must be a device id (adb devices or xcrun simctl list).")
|
|
518
|
+
const t = m.testmu === undefined || m.testmu === null || m.testmu === false ? undefined : asRecord(m.testmu)
|
|
519
|
+
let testmu: MobileConfig["maestro"]["testmu"]
|
|
520
|
+
if (t) {
|
|
521
|
+
const devices = Array.isArray(t.devices) ? t.devices.map(String) : []
|
|
522
|
+
if (!devices.length || devices.some((d) => !/^[A-Za-z0-9 ._*()+-]{1,60}$/.test(d))) throw new Error('mobile.maestro.testmu.devices must be a list of TestMu device names, e.g. ["Pixel 6-14"].')
|
|
523
|
+
const region = t.region === undefined ? undefined : String(t.region)
|
|
524
|
+
if (region !== undefined && !/^[a-z]{2,4}$/.test(region)) throw new Error("mobile.maestro.testmu.region must be a TestMu region code, e.g. us or ap.")
|
|
525
|
+
const named = t.cli === undefined ? "hyperexecute" : String(t.cli)
|
|
526
|
+
if (!named || /[\x00-\x1f]/.test(named)) throw new Error("mobile.maestro.testmu.cli must be the path of the hyperexecute program.")
|
|
527
|
+
// A path (./hyperexecute) is relative to forgeqe.yaml; a bare name is looked up on PATH.
|
|
528
|
+
const cli = /[\\/]/.test(named) && !path.isAbsolute(named) ? path.resolve(base, named) : named
|
|
529
|
+
const tEnv = (v: unknown, key: string, fallback: string) => {
|
|
530
|
+
const name = v === undefined ? fallback : v
|
|
531
|
+
if (typeof name !== "string" || !ENV_NAME.test(name)) throw new Error(`mobile.maestro.testmu.${key} must be the name of an environment variable.`)
|
|
532
|
+
return name
|
|
533
|
+
}
|
|
534
|
+
testmu = { cli, devices, realDevice: t.real_device !== false, ...(region ? { region } : {}), usernameEnv: tEnv(t.username_env, "username_env", "LT_USERNAME"), accessKeyEnv: tEnv(t.access_key_env, "access_key_env", "LT_ACCESS_KEY") }
|
|
535
|
+
}
|
|
536
|
+
const usernameEnv = envName(a.username_env, "username_env")
|
|
537
|
+
const accessKeyEnv = envName(a.access_key_env, "access_key_env")
|
|
538
|
+
return {
|
|
539
|
+
apps: asRecord(raw.apps),
|
|
540
|
+
secrets: [...new Set(secrets as string[])],
|
|
541
|
+
driver,
|
|
542
|
+
appium: { url, capabilities: asRecord(a.capabilities), ...(usernameEnv ? { usernameEnv } : {}), ...(accessKeyEnv ? { accessKeyEnv } : {}) },
|
|
543
|
+
maestro: { command, ...(device ? { device } : {}), ...(testmu ? { testmu } : {}) },
|
|
544
|
+
heal: raw.heal !== false,
|
|
545
|
+
screenshots: raw.screenshots !== false,
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
415
549
|
export type DomainConfig = {
|
|
416
550
|
/** In the team's words, e.g. "retail banking", "property and casualty insurance". */
|
|
417
551
|
industry: string
|
|
@@ -681,8 +815,12 @@ function profileFromRaw(name: string, raw: Record<string, unknown>): LlmProfile
|
|
|
681
815
|
...(String(raw.auth || "").toLowerCase() === "entra" ? { auth: "entra" as const } : {}),
|
|
682
816
|
managedIdentityClientId: raw.managed_identity_client_id ? String(raw.managed_identity_client_id) : undefined,
|
|
683
817
|
tokenScope: raw.token_scope ? String(raw.token_scope) : undefined,
|
|
818
|
+
...(raw.engine_model ? { engineModel: String(raw.engine_model) } : {}),
|
|
819
|
+
...(raw.github_org ? { githubOrg: String(raw.github_org) } : {}),
|
|
684
820
|
apiVersion: raw.api_version ? String(raw.api_version) : undefined,
|
|
685
821
|
temperature: typeof raw.temperature === "number" ? raw.temperature : undefined,
|
|
822
|
+
...(typeof raw.vision === "boolean" ? { vision: raw.vision } : {}),
|
|
823
|
+
...(typeof raw.reasoning === "boolean" ? { reasoning: raw.reasoning } : {}),
|
|
686
824
|
...priceFor(name, raw, provider, String(raw.model || "gpt-4o")),
|
|
687
825
|
}
|
|
688
826
|
}
|
|
@@ -797,7 +935,7 @@ function codeBudget(raw: unknown): number | undefined {
|
|
|
797
935
|
return n > 0 ? n : undefined
|
|
798
936
|
}
|
|
799
937
|
|
|
800
|
-
export function loadConfig(opts?: { configPath?: string; profile?: string }): AppConfig {
|
|
938
|
+
export function loadConfig(opts?: { configPath?: string; profile?: string; env?: string }): AppConfig {
|
|
801
939
|
// FORGEQE_CWD_ENV=off: tests never read the .env of the folder they run in (the developer's own keys).
|
|
802
940
|
if (process.env.FORGEQE_CWD_ENV !== "off") loadDotEnv(process.cwd())
|
|
803
941
|
const configPath = findConfigFile(opts?.configPath)
|
|
@@ -848,6 +986,10 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
848
986
|
activeProfile,
|
|
849
987
|
profiles,
|
|
850
988
|
llm: profiles[activeProfile] || { ...NO_PROFILE },
|
|
989
|
+
desktop: desktopConfig(asRecord(yaml.desktop)),
|
|
990
|
+
mobile: mobileConfig(asRecord(yaml.mobile), base),
|
|
991
|
+
containers: containersConfig(asRecord(yaml.containers), base),
|
|
992
|
+
agentScope: agentScopeConfig(asRecord(yaml.agent)),
|
|
851
993
|
browser: {
|
|
852
994
|
provider,
|
|
853
995
|
headless: browser.headless !== false,
|
|
@@ -871,6 +1013,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
871
1013
|
dirs: promptDirsFor(prompts, base, configPath),
|
|
872
1014
|
},
|
|
873
1015
|
knowledge: knowledgeConfig(asRecord(yaml.knowledge), base, configPath),
|
|
1016
|
+
guardrails: guardrailsConfig(asRecord(yaml.guardrails), base, configPath),
|
|
874
1017
|
defects: defectsConfig(asRecord(yaml.defects), base),
|
|
875
1018
|
export: {
|
|
876
1019
|
outDir,
|
|
@@ -929,7 +1072,16 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
929
1072
|
}
|
|
930
1073
|
// Prompts resolve from these folders from now on (src/prompts.ts).
|
|
931
1074
|
setPromptDirs(config.prompts.dirs || [])
|
|
932
|
-
|
|
1075
|
+
const environments = loadEnvironments(asRecord(yaml.environments), base, config.desktop, opts?.env || process.env.FORGEQE_ENV || undefined, config.browser.allowedHosts, {
|
|
1076
|
+
startUrl: config.browser.startUrl,
|
|
1077
|
+
apiUrl: config.api?.baseUrl,
|
|
1078
|
+
mobile: config.mobile,
|
|
1079
|
+
})
|
|
1080
|
+
if (!environments) return config
|
|
1081
|
+
const withEnv = withEnvironment({ ...config, environments })
|
|
1082
|
+
// Tests and agents ForgeQE starts read the environment from FORGEQE_ACTIVE_ENV and FORGEQE_APP_* (src/environments.ts).
|
|
1083
|
+
exportEnvironment(withEnv)
|
|
1084
|
+
return withEnv
|
|
933
1085
|
}
|
|
934
1086
|
|
|
935
1087
|
function skillsConfig(raw: unknown, base: string): SkillsConfig {
|
|
@@ -1187,6 +1339,10 @@ export function defectsConfig(d: Record<string, unknown>, base: string): Defects
|
|
|
1187
1339
|
}
|
|
1188
1340
|
}
|
|
1189
1341
|
|
|
1342
|
+
function guardrailsConfig(g: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["guardrails"]> {
|
|
1343
|
+
return { dir: g.dir ? path.resolve(base, String(g.dir)) : configPath ? path.join(projectDir(base), "guardrails") : path.join(forgeqeHome(), "guardrails") }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1190
1346
|
function knowledgeConfig(k: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["knowledge"]> {
|
|
1191
1347
|
const dir = k.dir ? path.resolve(base, String(k.dir)) : configPath ? path.join(projectDir(base), "knowledge") : path.join(forgeqeHome(), "knowledge")
|
|
1192
1348
|
const max = Number(k.browser_max_chars)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { configOpts, strFlag, type Flags } from "../cli/args.ts"
|
|
2
|
+
import { loadConfig } from "../config.ts"
|
|
3
|
+
import { containersOf, startOrder } from "./config.ts"
|
|
4
|
+
import { findRuntime, listContainers, runtimeVersion, serviceLogs, spawnExec, startService, stopServices, type Ctx } from "./run.ts"
|
|
5
|
+
|
|
6
|
+
/** `forgeqe services`: the project's services in Docker or Podman (docs/CONTAINERS.md). */
|
|
7
|
+
|
|
8
|
+
export const SERVICES_USAGE = `Usage:
|
|
9
|
+
forgeqe services [status] the declared services, what runs, and whether Docker or Podman answers
|
|
10
|
+
forgeqe services up [name ...] start services (default: all) and wait until each is ready; they keep
|
|
11
|
+
running after this command, until forgeqe services down
|
|
12
|
+
forgeqe services down [name ...] stop and remove this project's containers (default: all, mocks included)
|
|
13
|
+
forgeqe services logs <name> [--lines N] a service's last lines, secrets removed`
|
|
14
|
+
|
|
15
|
+
export async function cmdServices(args: string[], flags: Flags): Promise<number> {
|
|
16
|
+
const cfg = loadConfig(configOpts(flags))
|
|
17
|
+
const [op = "status", ...names] = args
|
|
18
|
+
const declared = containersOf(cfg).services
|
|
19
|
+
const runtime = findRuntime(cfg)
|
|
20
|
+
if (!runtime) {
|
|
21
|
+
console.error(`${containersOf(cfg).runtime || "Docker or Podman"} is not on PATH. Install Docker Desktop, Docker Engine or Podman.`)
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
const ctx: Ctx = { cfg, runtime, exec: spawnExec, log: (t) => console.log(t) }
|
|
25
|
+
if (op === "status") {
|
|
26
|
+
const v = await runtimeVersion(ctx)
|
|
27
|
+
console.log(v.ok ? `${runtime.name} ${v.version}` : `${runtime.name} does not answer: ${v.error}`)
|
|
28
|
+
const rows = v.ok ? await listContainers(ctx) : []
|
|
29
|
+
const names = Object.keys(declared)
|
|
30
|
+
console.log(names.length ? `Declared: ${names.map((n) => `${n} (${declared[n]!.image})`).join(", ")}` : "No services declared (containers.services in forgeqe.yaml).")
|
|
31
|
+
for (const r of rows) console.log(` ${r.service.padEnd(24)} ${r.state.padEnd(10)} ${r.ports || ""}`)
|
|
32
|
+
if (v.ok && !rows.length) console.log("Nothing of this project is running.")
|
|
33
|
+
return v.ok ? 0 : 1
|
|
34
|
+
}
|
|
35
|
+
const unknown = names.filter((n) => !declared[n] && !(op !== "up" && n.startsWith("mock-")))
|
|
36
|
+
if (unknown.length) {
|
|
37
|
+
console.error(`Not declared in forgeqe.yaml containers.services: ${unknown.join(", ")} (declared: ${Object.keys(declared).join(", ") || "none"}).`)
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
if (op === "up") {
|
|
41
|
+
const specs = startOrder(declared, names)
|
|
42
|
+
if (!specs.length) {
|
|
43
|
+
console.error("No services declared: add them under containers.services in forgeqe.yaml (docs/CONTAINERS.md).")
|
|
44
|
+
return 1
|
|
45
|
+
}
|
|
46
|
+
for (const s of specs) {
|
|
47
|
+
const r = await startService(ctx, s, { keep: true })
|
|
48
|
+
if (!r.ready) {
|
|
49
|
+
console.error(`✗ ${s.name}: ${r.error}${r.logs ? `\n${r.logs}` : ""}`)
|
|
50
|
+
return 1
|
|
51
|
+
}
|
|
52
|
+
console.log(`✓ ${s.name} ready${r.ports.length ? ` on ${r.ports.map((p) => p.address).join(", ")}` : ""} (${(r.ms / 1000).toFixed(1)} s)`)
|
|
53
|
+
}
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
if (op === "down") {
|
|
57
|
+
const stopped = await stopServices(ctx, names.length ? names : undefined)
|
|
58
|
+
console.log(stopped.length ? `Stopped ${stopped.join(", ")}.` : "Nothing of this project was running.")
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
if (op === "logs") {
|
|
62
|
+
if (names.length !== 1) throw new Error(SERVICES_USAGE)
|
|
63
|
+
const lines = strFlag(flags, "lines")
|
|
64
|
+
console.log(await serviceLogs(ctx, names[0]!, lines ? Number(lines) : 100))
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
throw new Error(SERVICES_USAGE)
|
|
68
|
+
}
|