@capagents/forgeqe 0.0.0-stage → 1.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.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,494 @@
1
+ import { spawn, spawnSync } from "node:child_process"
2
+
3
+ import { azCliAuth, resetAzCliAuth } from "../cases/ado.ts"
4
+ import { basic, http } from "../cases/http.ts"
5
+ import type { AppConfig } from "../config.ts"
6
+ import type { AskQuestion } from "../mcp/tools.ts"
7
+ import { withoutTelemetry } from "../privacy.ts"
8
+ import { forgetSystem, fromSaved, readIndex, savedSecrets, saveSystem, storeName, type Via } from "./store.ts"
9
+
10
+ /** How a sign-in talks to the user: the terminal (`forgeqe auth`) or the shell's dialogs (/auth). */
11
+ export type AuthIO = {
12
+ say(text: string, kind?: "info" | "ok" | "err"): void
13
+ /** A line of text; `secret` hides what is typed. null when the user cancels. */
14
+ input(q: { label: string; secret?: boolean; initial?: string }): Promise<string | null>
15
+ choose(q: AskQuestion): Promise<string | null>
16
+ openUrl(url: string): void
17
+ /** az and gh can take over the terminal (`forgeqe auth`); in the shell their output is read and shown instead. */
18
+ attached?: boolean
19
+ signal?: AbortSignal
20
+ }
21
+
22
+ export type SystemId = "azure_devops" | "github" | "jira" | "confluence" | "testrail" | "zephyr" | "qtest" | "xray"
23
+
24
+ export const SYSTEMS: { id: SystemId; label: string; aliases: string[] }[] = [
25
+ { id: "azure_devops", label: "Azure DevOps", aliases: ["ado", "azure", "devops", "azdo", "azure-devops"] },
26
+ { id: "github", label: "GitHub", aliases: ["gh"] },
27
+ { id: "jira", label: "Jira", aliases: ["atlassian"] },
28
+ { id: "confluence", label: "Confluence", aliases: ["wiki"] },
29
+ { id: "testrail", label: "TestRail", aliases: [] },
30
+ { id: "zephyr", label: "Zephyr Scale", aliases: ["zephyr-scale"] },
31
+ { id: "qtest", label: "qTest", aliases: [] },
32
+ { id: "xray", label: "Xray Cloud", aliases: [] },
33
+ ]
34
+
35
+ /** A system id from what the user typed ("ado", "Azure DevOps", "jira"); null when it names none. */
36
+ export function findSystem(name: string): SystemId | null {
37
+ const n = name.trim().toLowerCase().replace(/\s+/g, "_")
38
+ if (!n) return null
39
+ const s = SYSTEMS.find((x) => x.id === n || x.label.toLowerCase().replace(/\s+/g, "_") === n || x.aliases.includes(n.replace(/_/g, "-")) || x.aliases.includes(n))
40
+ return s?.id || null
41
+ }
42
+
43
+ export type SignInResult = { ok: boolean; system: string; message: string; account?: string }
44
+
45
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
46
+
47
+ /** The environment variable names the config reads for each system (forgeqe.yaml can rename them). */
48
+ export function envNames(cfg?: AppConfig) {
49
+ const s = cfg?.sources
50
+ return {
51
+ ado: { url: "ADO_ORG_URL", token: s?.ado?.tokenEnv || "ADO_PAT" },
52
+ jira: { url: "JIRA_URL", email: s?.jira?.emailEnv || "JIRA_EMAIL", token: s?.jira?.tokenEnv || "JIRA_API_TOKEN" },
53
+ confluence: { url: "CONFLUENCE_URL", email: s?.confluence?.emailEnv || "CONFLUENCE_EMAIL", token: s?.confluence?.tokenEnv || "CONFLUENCE_TOKEN" },
54
+ testrail: { url: "TESTRAIL_URL", email: s?.testrail?.emailEnv || "TESTRAIL_EMAIL", token: s?.testrail?.tokenEnv || "TESTRAIL_API_KEY" },
55
+ zephyr: { url: "ZEPHYR_URL", token: s?.zephyr?.tokenEnv || "ZEPHYR_API_TOKEN" },
56
+ qtest: { url: "QTEST_URL", project: "QTEST_PROJECT_ID", token: s?.qtest?.tokenEnv || "QTEST_TOKEN" },
57
+ xray: { url: "XRAY_URL", id: s?.xray.clientIdEnv || "XRAY_CLIENT_ID", secret: s?.xray.clientSecretEnv || "XRAY_CLIENT_SECRET" },
58
+ }
59
+ }
60
+
61
+ /** Open a page in the default browser. */
62
+ export function openInBrowser(url: string): void {
63
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]]
64
+ try {
65
+ spawn(cmd as string, args as string[], { stdio: "ignore", detached: true }).unref()
66
+ } catch {}
67
+ }
68
+
69
+ // ---- Small helpers ----
70
+
71
+ const httpsUrl = (raw: string) => {
72
+ const t = raw.trim().replace(/\/+$/, "")
73
+ return /^https?:\/\//i.test(t) ? t : `https://${t}`
74
+ }
75
+
76
+ /** "acme", "dev.azure.com/acme/Shop", or a visualstudio.com URL → the organization URL. */
77
+ export function adoOrgUrl(raw: string): string {
78
+ const t = raw.trim()
79
+ if (/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(t)) return `https://dev.azure.com/${t}`
80
+ const u = new URL(httpsUrl(t))
81
+ if (u.hostname === "dev.azure.com") return `${u.origin}/${u.pathname.split("/").filter(Boolean)[0] || ""}`.replace(/\/+$/, "")
82
+ if (u.hostname.endsWith(".visualstudio.com")) return u.origin
83
+ return `${u.origin}${u.pathname}`.replace(/\/+$/, "")
84
+ }
85
+
86
+ /** An Atlassian Cloud site's root (https://acme.atlassian.net) from any page on it. */
87
+ const siteRoot = (raw: string) => {
88
+ const u = new URL(httpsUrl(raw))
89
+ return u.hostname.endsWith(".atlassian.net") ? u.origin : `${u.origin}${u.pathname}`.replace(/\/+$/, "")
90
+ }
91
+
92
+ const isCloud = (url: string) => new URL(url).hostname.endsWith(".atlassian.net")
93
+
94
+ function gitEmail(): string | undefined {
95
+ const r = spawnSync("git", ["config", "user.email"], { encoding: "utf8" })
96
+ return r.status === 0 ? r.stdout.trim() || undefined : undefined
97
+ }
98
+
99
+ async function ask(io: AuthIO, label: string, opts: { secret?: boolean; initial?: string } = {}): Promise<string> {
100
+ const v = await io.input({ label, ...opts })
101
+ if (v === null || !v.trim()) throw new Cancelled()
102
+ return v.trim()
103
+ }
104
+
105
+ class Cancelled extends Error {
106
+ constructor() {
107
+ super("Cancelled.")
108
+ }
109
+ }
110
+
111
+ /** A value the config already has: from forgeqe.yaml, the environment, or an earlier sign-in. */
112
+ const known = (name: string, fromConfig?: string) => fromConfig || process.env[name] || undefined
113
+
114
+ /**
115
+ * Run az or gh. With `attached` it takes over the terminal; otherwise its output is read line by line and shown, and
116
+ * `onLine` can answer it on stdin (gh's "Press Enter"). Resolves with the exit code; null when stopped or timed out.
117
+ */
118
+ function runTool(io: AuthIO, cmd: string, args: string[], opts: { env?: Record<string, string>; stdin?: string; onLine?: (line: string, write: (s: string) => void) => void; timeoutMs?: number } = {}): Promise<number | null> {
119
+ return new Promise((resolve) => {
120
+ const child = spawn(cmd, args, {
121
+ env: { ...withoutTelemetry(process.env), ...opts.env },
122
+ stdio: io.attached && opts.stdin === undefined ? "inherit" : ["pipe", "pipe", "pipe"],
123
+ shell: process.platform === "win32",
124
+ })
125
+ const write = (s: string) => child.stdin?.write(s)
126
+ if (opts.stdin !== undefined) child.stdin?.end(opts.stdin)
127
+ let buf = ""
128
+ const onData = (chunk: Buffer) => {
129
+ buf += chunk.toString("utf8")
130
+ const parts = buf.split(/\r?\n/)
131
+ buf = parts.pop() || ""
132
+ for (const line of parts) {
133
+ if (!line.trim()) continue
134
+ if (opts.onLine) opts.onLine(line, write)
135
+ else io.say(line)
136
+ }
137
+ }
138
+ child.stdout?.on("data", onData)
139
+ child.stderr?.on("data", onData)
140
+ const timer = setTimeout(() => child.kill(), opts.timeoutMs ?? 10 * 60_000)
141
+ const stop = () => child.kill()
142
+ io.signal?.addEventListener("abort", stop, { once: true })
143
+ child.on("error", () => resolve(null))
144
+ child.on("close", (code) => {
145
+ clearTimeout(timer)
146
+ io.signal?.removeEventListener("abort", stop)
147
+ resolve(io.signal?.aborted ? null : code)
148
+ })
149
+ })
150
+ }
151
+
152
+ /** Save, put the values into this process too, and say so; a value exported in the terminal still wins next time. */
153
+ async function finish(io: AuthIO, id: SystemId, rec: { via: Via; env: Record<string, string>; secrets: Record<string, string>; account?: string }, what: string): Promise<SignInResult> {
154
+ const shadowed = Object.keys({ ...rec.env, ...rec.secrets }).filter((n) => process.env[n] !== undefined && !fromSaved.has(n) && process.env[n] !== ({ ...rec.env, ...rec.secrets })[n])
155
+ const where = await saveSystem(id, rec)
156
+ for (const [n, v] of Object.entries({ ...rec.env, ...rec.secrets })) {
157
+ process.env[n] = v
158
+ fromSaved.add(n)
159
+ }
160
+ for (const n of Object.keys(rec.secrets)) savedSecrets.add(n)
161
+ const label = SYSTEMS.find((s) => s.id === id)!.label
162
+ const lines = [`Signed in to ${label}${rec.account ? ` as ${rec.account}` : ""} (${what}).`]
163
+ if (Object.keys(rec.secrets).length) lines.push(`Saved in ${where}.`)
164
+ if (shadowed.length) lines.push(`${shadowed.join(", ")} ${shadowed.length === 1 ? "is" : "are"} also set in this terminal or a .env file: a value exported in the terminal is used instead of this sign-in.`)
165
+ io.say(lines.join(" "), "ok")
166
+ return { ok: true, system: id, message: lines.join(" "), account: rec.account }
167
+ }
168
+
169
+ const describeError = (err: unknown) => (err as Error).message.replace(/\s+/g, " ").slice(0, 400)
170
+
171
+ // ---- The systems ----
172
+
173
+ async function signInAdo(io: AuthIO, cfg?: AppConfig): Promise<SignInResult> {
174
+ const names = envNames(cfg).ado
175
+ const org = adoOrgUrl(known(names.url, cfg?.sources.ado?.orgUrl) || (await ask(io, "Azure DevOps organization (a name like acme, or its URL)")))
176
+ const hasAz = Boolean(Bun.which("az"))
177
+ const cloud = /dev\.azure\.com|visualstudio\.com/.test(org)
178
+ let method = "pat"
179
+ if (hasAz && cloud) {
180
+ method =
181
+ (await io.choose({
182
+ title: `Sign in to Azure DevOps (${org})`,
183
+ detail: "Signing in with Microsoft uses the Azure CLI (az login) in your browser; nothing is saved by ForgeQE, and az renews the sign-in itself.",
184
+ options: [
185
+ { id: "az", label: "Sign in with Microsoft in the browser" },
186
+ { id: "pat", label: "Paste a personal access token" },
187
+ { id: "cancel", label: "Cancel" },
188
+ ],
189
+ })) || "cancel"
190
+ if (method === "cancel") throw new Cancelled()
191
+ }
192
+ const who = async (auth: string) => {
193
+ const { data } = await http<{ authenticatedUser?: { providerDisplayName?: string; properties?: { Account?: { $value?: string } } } }>(`${org}/_apis/connectionData`, { auth })
194
+ const u = data?.authenticatedUser
195
+ if (!u || /anonymous/i.test(u.providerDisplayName || "")) throw new Error("Azure DevOps did not accept the sign-in for this organization.")
196
+ return u.properties?.Account?.$value || u.providerDisplayName || undefined
197
+ }
198
+
199
+ if (method === "az") {
200
+ resetAzCliAuth()
201
+ let auth = azCliAuth()
202
+ if (!auth) {
203
+ io.say("Opening the Microsoft sign-in in your browser (az login)…")
204
+ const code = await runTool(io, "az", ["login", "--only-show-errors", "--output", "none"], { env: { AZURE_CORE_LOGIN_EXPERIENCE_V2: "off" } })
205
+ if (code !== 0) throw new Error("az login did not finish.")
206
+ resetAzCliAuth()
207
+ auth = azCliAuth()
208
+ if (!auth) throw new Error("az login finished, but the Azure CLI gave no token for Azure DevOps.")
209
+ }
210
+ let account: string | undefined
211
+ try {
212
+ account = await who(auth)
213
+ } catch (err) {
214
+ const tenant = await io.input({ label: `Azure DevOps did not accept the Azure CLI's account (${describeError(err)}). The organization may be in another Microsoft tenant: its domain or id (empty to stop)` })
215
+ if (!tenant?.trim()) throw new Error("The Azure CLI's account has no access to this organization. Try a personal access token instead.")
216
+ const code = await runTool(io, "az", ["login", "--tenant", tenant.trim(), "--only-show-errors", "--output", "none"], { env: { AZURE_CORE_LOGIN_EXPERIENCE_V2: "off" } })
217
+ resetAzCliAuth()
218
+ auth = code === 0 ? azCliAuth() : null
219
+ if (!auth) throw new Error("az login did not finish.")
220
+ account = await who(auth)
221
+ }
222
+ // Without a token, Azure DevOps calls and its MCP server use the Azure CLI's sign-in.
223
+ return finish(io, "azure_devops", { via: "azcli", env: { [names.url]: org }, secrets: {}, account }, "Azure CLI sign-in")
224
+ }
225
+
226
+ io.openUrl(`${org}/_usersSettings/tokens`)
227
+ io.say(
228
+ `Your browser shows ${org}/_usersSettings/tokens. Choose New Token, pick an expiry, and give it these scopes: Work Items (Read & write), Test Management (Read & write), Code (Read & write, for pull request reviews), Wiki (Read & write), Project and Team (Read), Build (Read). Then copy the token.`,
229
+ )
230
+ const token = await ask(io, "Paste the personal access token", { secret: true })
231
+ const account = await who(basic("", token))
232
+ return finish(io, "azure_devops", { via: "token", env: { [names.url]: org }, secrets: { [names.token]: token }, account }, "personal access token")
233
+ }
234
+
235
+ function ghSignedIn(): string | null {
236
+ const r = spawnSync("gh", ["api", "user", "--jq", ".login"], { encoding: "utf8", env: { ...withoutTelemetry(process.env), GH_PROMPT_DISABLED: "1" }, shell: process.platform === "win32" })
237
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null
238
+ }
239
+
240
+ async function signInGithub(io: AuthIO): Promise<SignInResult> {
241
+ if (!Bun.which("gh")) {
242
+ io.openUrl("https://cli.github.com/")
243
+ throw new Error("GitHub sign-in uses the GitHub CLI (gh), which is not installed. Install it (brew install gh, winget install GitHub.cli, or cli.github.com) and try again.")
244
+ }
245
+ const current = ghSignedIn()
246
+ if (current) {
247
+ const again = await io.choose({
248
+ title: `GitHub: signed in as ${current}`,
249
+ detail: "The GitHub CLI already has a sign-in, which ForgeQE uses for pull requests and CI secrets.",
250
+ options: [
251
+ { id: "keep", label: `Keep ${current}` },
252
+ { id: "again", label: "Sign in again" },
253
+ ],
254
+ })
255
+ if (again !== "again") return finish(io, "github", { via: "gh", env: {}, secrets: {}, account: current }, "GitHub CLI")
256
+ }
257
+ const method =
258
+ (await io.choose({
259
+ title: "Sign in to GitHub",
260
+ detail: "ForgeQE works through the GitHub CLI (gh), which keeps the sign-in in your system's credential store.",
261
+ options: [
262
+ { id: "web", label: "Sign in with GitHub in the browser" },
263
+ { id: "token", label: "Paste a personal access token" },
264
+ { id: "cancel", label: "Cancel" },
265
+ ],
266
+ })) || "cancel"
267
+ if (method === "cancel") throw new Cancelled()
268
+ let code: number | null
269
+ if (method === "web") {
270
+ let shown = false
271
+ code = await runTool(io, "gh", ["auth", "login", "--hostname", "github.com", "--git-protocol", "https", "--web", "--scopes", "repo,workflow,read:org"], {
272
+ onLine: (line, write) => {
273
+ const otp = /one-time code:\s*([A-Z0-9]{4}-[A-Z0-9]{4})/i.exec(line)?.[1]
274
+ if (otp && !shown) {
275
+ shown = true
276
+ io.say(`Your one-time code is ${otp}. Enter it on the GitHub page that opens in your browser.`)
277
+ io.openUrl("https://github.com/login/device")
278
+ write("\n")
279
+ } else if (!/Press Enter|Open this URL|github\.com\/login\/device/i.test(line)) io.say(line.replace(/^[!✓-]\s*/, ""))
280
+ },
281
+ })
282
+ } else {
283
+ io.openUrl("https://github.com/settings/tokens/new?scopes=repo,workflow,read:org&description=ForgeQE")
284
+ io.say("Your browser shows GitHub's new token page with the scopes ForgeQE needs (repo, workflow, read:org). Generate the token and copy it.")
285
+ const token = await ask(io, "Paste the GitHub token", { secret: true })
286
+ code = await runTool({ ...io, attached: false }, "gh", ["auth", "login", "--hostname", "github.com", "--git-protocol", "https", "--with-token"], { stdin: `${token}\n` })
287
+ }
288
+ const account = ghSignedIn()
289
+ if (code !== 0 || !account) throw new Error("The GitHub CLI did not finish signing in.")
290
+ return finish(io, "github", { via: "gh", env: {}, secrets: {}, account }, "GitHub CLI")
291
+ }
292
+
293
+ async function signInAtlassian(io: AuthIO, cfg: AppConfig | undefined, which: "jira" | "confluence"): Promise<SignInResult> {
294
+ const names = envNames(cfg)[which]
295
+ const label = which === "jira" ? "Jira" : "Confluence"
296
+ const configured = which === "jira" ? cfg?.sources.jira?.url : cfg?.sources.confluence?.url
297
+ const example = which === "jira" ? "https://acme.atlassian.net" : "https://acme.atlassian.net/wiki"
298
+ let url = siteRoot(known(names.url, configured) || (await ask(io, `${label} site URL (e.g. ${example})`)))
299
+ const cloud = isCloud(url)
300
+ if (which === "confluence" && cloud && !url.endsWith("/wiki")) url = `${url}/wiki`
301
+ const me = which === "jira" ? `${url}/rest/api/${cloud ? 3 : 2}/myself` : `${url}/rest/api/user/current`
302
+ const who = async (auth: string) => {
303
+ const { data } = await http<{ displayName?: string; emailAddress?: string; type?: string }>(me, { auth })
304
+ if (!data?.displayName || data.type === "anonymous") throw new Error(`${label} did not accept the token.`)
305
+ return data.displayName
306
+ }
307
+
308
+ // Confluence on the Jira Cloud site: the Jira account works, and the config reuses its variables.
309
+ const jira = envNames(cfg).jira
310
+ const jiraUrl = cfg?.sources.jira?.url || process.env[jira.url]
311
+ if (which === "confluence" && cloud && jiraUrl && new URL(jiraUrl).host === new URL(url).host && process.env[jira.email] && process.env[jira.token]) {
312
+ const account = await who(basic(process.env[jira.email]!, process.env[jira.token]!))
313
+ return finish(io, "confluence", { via: "token", env: { [names.url]: url }, secrets: {}, account }, "the Jira sign-in for this site")
314
+ }
315
+
316
+ if (cloud) {
317
+ const email = await ask(io, "Atlassian account email", { initial: process.env[names.email] || gitEmail() })
318
+ io.openUrl("https://id.atlassian.com/manage-profile/security/api-tokens")
319
+ io.say("Your browser shows your Atlassian API tokens. Choose Create API token, name it (for example ForgeQE), and copy it.")
320
+ const token = await ask(io, "Paste the API token", { secret: true })
321
+ const account = await who(basic(email, token))
322
+ return finish(io, which, { via: "token", env: { [names.url]: url, [names.email]: email }, secrets: { [names.token]: token }, account }, "API token")
323
+ }
324
+ io.openUrl(which === "jira" ? `${url}/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens` : `${url}/plugins/personalaccesstokens/usertokens.action`)
325
+ io.say(`Your browser shows your ${label} personal access tokens. Create a token and copy it.`)
326
+ const token = await ask(io, "Paste the personal access token", { secret: true })
327
+ const account = await who(`Bearer ${token}`)
328
+ return finish(io, which, { via: "token", env: { [names.url]: url }, secrets: { [names.token]: token }, account }, "personal access token")
329
+ }
330
+
331
+ async function signInTestrail(io: AuthIO, cfg?: AppConfig): Promise<SignInResult> {
332
+ const names = envNames(cfg).testrail
333
+ const url = httpsUrl(known(names.url, cfg?.sources.testrail?.url) || (await ask(io, "TestRail URL (e.g. https://acme.testrail.io)")))
334
+ const email = await ask(io, "TestRail email", { initial: process.env[names.email] || gitEmail() })
335
+ io.openUrl(`${url}/index.php?/mysettings`)
336
+ io.say("Your browser shows your TestRail settings. On the API Keys tab, add a key, save the settings, and copy the key.")
337
+ const key = await ask(io, "Paste the API key", { secret: true })
338
+ const { data } = await http<{ name?: string }>(`${url}/index.php?/api/v2/get_user_by_email&email=${encodeURIComponent(email)}`, { auth: basic(email, key) })
339
+ return finish(io, "testrail", { via: "token", env: { [names.url]: url, [names.email]: email }, secrets: { [names.token]: key }, account: data?.name || email }, "API key")
340
+ }
341
+
342
+ async function signInZephyr(io: AuthIO, cfg?: AppConfig): Promise<SignInResult> {
343
+ const names = envNames(cfg).zephyr
344
+ const url = (cfg?.sources.zephyr?.url || process.env[names.url] || "https://api.zephyrscale.smartbear.com/v2").replace(/\/+$/, "")
345
+ const jira = cfg?.sources.jira?.url || process.env.JIRA_URL
346
+ if (jira) io.openUrl(jira)
347
+ io.say("In Jira, open your profile picture menu, choose Zephyr API keys (Zephyr Scale Cloud), and generate a key. Then copy it.")
348
+ const token = await ask(io, "Paste the Zephyr Scale API key", { secret: true })
349
+ await http(`${url}/projects?maxResults=1`, { auth: `Bearer ${token}` })
350
+ const env = url === "https://api.zephyrscale.smartbear.com/v2" ? {} : { [names.url]: url }
351
+ return finish(io, "zephyr", { via: "token", env, secrets: { [names.token]: token } }, "API key")
352
+ }
353
+
354
+ async function signInQtest(io: AuthIO, cfg?: AppConfig): Promise<SignInResult> {
355
+ const names = envNames(cfg).qtest
356
+ const url = httpsUrl(known(names.url, cfg?.sources.qtest?.url) || (await ask(io, "qTest Manager URL (e.g. https://acme.qtestnet.com)")))
357
+ const project = String(cfg?.sources.qtest?.projectId || process.env[names.project] || (await ask(io, "qTest project id (the number in the project's URL)")))
358
+ io.openUrl(url)
359
+ io.say("In qTest Manager, open Resources, then API & SDK, and copy your API access token.")
360
+ const raw = await ask(io, "Paste the access token", { secret: true })
361
+ const token = raw.replace(/^bearer\s+/i, "")
362
+ const { data } = await http<{ name?: string }>(`${url}/api/v3/projects/${encodeURIComponent(project)}`, { auth: `Bearer ${token}` })
363
+ return finish(io, "qtest", { via: "token", env: { [names.url]: url, [names.project]: project }, secrets: { [names.token]: token }, account: data?.name ? `project ${data.name}` : undefined }, "access token")
364
+ }
365
+
366
+ async function signInXray(io: AuthIO, cfg?: AppConfig): Promise<SignInResult> {
367
+ const names = envNames(cfg).xray
368
+ const url = (cfg?.sources.xray.url || process.env[names.url] || "https://xray.cloud.getxray.app").replace(/\/+$/, "")
369
+ const jira = cfg?.sources.jira?.url || process.env.JIRA_URL
370
+ if (jira && !isCloud(jira)) throw new Error("Xray Server/Data Center uses the Jira sign-in: run the Jira sign-in instead.")
371
+ if (jira) io.openUrl(`${jira}/plugins/servlet/ac/com.xpandit.plugins.xray/xray-configuration-page`)
372
+ io.say("In Jira, open Apps, then Xray, Settings, API Keys, and create a key (it needs a Jira administrator). Copy the client id and the client secret.")
373
+ const id = await ask(io, "Paste the client id", { secret: true })
374
+ const secret = await ask(io, "Paste the client secret", { secret: true })
375
+ await http(`${url}/api/v2/authenticate`, { auth: "", body: { client_id: id, client_secret: secret } })
376
+ return finish(io, "xray", { via: "token", env: {}, secrets: { [names.id]: id, [names.secret]: secret } }, "API key")
377
+ }
378
+
379
+ /** Save one secret by its environment variable name: an LLM API key, a webhook URL, an SMTP or login password. */
380
+ export async function saveNamedSecret(io: AuthIO, name: string): Promise<SignInResult> {
381
+ if (!ENV_NAME.test(name)) return { ok: false, system: name, message: `"${name}" is not an environment variable name.` }
382
+ try {
383
+ const value = await ask(io, `Paste the value for ${name}`, { secret: true })
384
+ const where = await saveSystem(`env:${name}`, { via: "token", env: {}, secrets: { [name]: value } })
385
+ process.env[name] = value
386
+ fromSaved.add(name)
387
+ savedSecrets.add(name)
388
+ const message = `${name} saved in ${where}.`
389
+ io.say(message, "ok")
390
+ return { ok: true, system: name, message }
391
+ } catch (err) {
392
+ const message = err instanceof Cancelled ? "Cancelled." : describeError(err)
393
+ io.say(message, err instanceof Cancelled ? "info" : "err")
394
+ return { ok: false, system: name, message }
395
+ }
396
+ }
397
+
398
+ /** Sign in to one system, asking which when `system` is empty. */
399
+ export async function signIn(io: AuthIO, system: string | undefined, cfg?: AppConfig): Promise<SignInResult> {
400
+ let id = system ? findSystem(system) : null
401
+ if (system && !id) {
402
+ if (ENV_NAME.test(system) && system === system.toUpperCase()) return saveNamedSecret(io, system)
403
+ const message = `No system called "${system}". Choose one of: ${SYSTEMS.map((s) => s.id).join(", ")}, or give an environment variable name (like OPENAI_API_KEY) to save that secret.`
404
+ io.say(message, "err")
405
+ return { ok: false, system, message }
406
+ }
407
+ if (!id) {
408
+ const pick = await io.choose({
409
+ title: "Sign in to which system?",
410
+ options: [...SYSTEMS.map((s) => ({ id: s.id, label: s.label })), { id: "cancel", label: "Cancel" }],
411
+ })
412
+ if (!pick || pick === "cancel") return { ok: false, system: "", message: "Cancelled." }
413
+ id = pick as SystemId
414
+ }
415
+ try {
416
+ switch (id) {
417
+ case "azure_devops":
418
+ return await signInAdo(io, cfg)
419
+ case "github":
420
+ return await signInGithub(io)
421
+ case "jira":
422
+ case "confluence":
423
+ return await signInAtlassian(io, cfg, id)
424
+ case "testrail":
425
+ return await signInTestrail(io, cfg)
426
+ case "zephyr":
427
+ return await signInZephyr(io, cfg)
428
+ case "qtest":
429
+ return await signInQtest(io, cfg)
430
+ case "xray":
431
+ return await signInXray(io, cfg)
432
+ }
433
+ return { ok: false, system: id, message: "Unknown system." }
434
+ } catch (err) {
435
+ const message = err instanceof Cancelled || io.signal?.aborted ? "Cancelled." : `Sign-in failed: ${describeError(err)}`
436
+ io.say(message, err instanceof Cancelled ? "info" : "err")
437
+ return { ok: false, system: id, message }
438
+ }
439
+ }
440
+
441
+ /** Forget a saved sign-in; az and gh keep their own. */
442
+ export async function signOut(system: string): Promise<string> {
443
+ const id = findSystem(system) || (ENV_NAME.test(system) ? `env:${system}` : null)
444
+ if (!id) return `No system called "${system}".`
445
+ const rec = readIndex().systems[id]
446
+ if (!(await forgetSystem(id))) return `Nothing is saved for ${system}.`
447
+ for (const n of [...Object.keys(rec?.env || {}), ...(rec?.secrets || [])]) {
448
+ if (fromSaved.has(n)) {
449
+ delete process.env[n]
450
+ fromSaved.delete(n)
451
+ }
452
+ }
453
+ const label = SYSTEMS.find((s) => s.id === id)?.label || system
454
+ if (rec?.via === "azcli") return `Forgot the ${label} sign-in. The Azure CLI is still signed in; run az logout to sign it out too.`
455
+ if (rec?.via === "gh") return `Forgot the ${label} sign-in. The GitHub CLI is still signed in; run gh auth logout to sign it out too.`
456
+ return `Forgot the ${label} sign-in and removed its secrets from ${storeName()}.`
457
+ }
458
+
459
+ /** One line per system: signed in how and as whom, set in the environment, or not set up. */
460
+ export function authStatus(cfg?: AppConfig): string[] {
461
+ const n = envNames(cfg)
462
+ const saved = readIndex().systems
463
+ const source = (names: string[]) => {
464
+ const missing = names.filter((x) => !process.env[x])
465
+ if (missing.length) return null
466
+ return names.some((x) => fromSaved.has(x)) ? "saved sign-in" : "environment"
467
+ }
468
+ const row = (id: string, label: string, how: string | null) => {
469
+ const s = saved[id]
470
+ const who = s?.account ? ` as ${s.account}` : ""
471
+ return `${label.padEnd(14)}${how ? `${how}${who}` : "not signed in"}`
472
+ }
473
+ const adoUrl = cfg?.sources.ado?.orgUrl || process.env[n.ado.url]
474
+ const ado = adoUrl ? source([n.ado.token]) || (saved.azure_devops?.via === "azcli" && azCliAuth() ? "Azure CLI sign-in" : null) : null
475
+ const ghUser = Bun.which("gh") ? ghSignedIn() : null
476
+ const jiraUrl = cfg?.sources.jira?.url || process.env[n.jira.url]
477
+ const jira = jiraUrl ? source(isCloud(jiraUrl) ? [n.jira.email, n.jira.token] : [n.jira.token]) : null
478
+ const conf = cfg?.sources.confluence
479
+ const confHow = conf ? source(conf.cloud ? [conf.emailEnv, conf.tokenEnv] : [conf.tokenEnv]) : null
480
+ const lines = [
481
+ row("azure_devops", "Azure DevOps", ado && `${ado}, ${adoUrl}`),
482
+ `${"GitHub".padEnd(14)}${ghUser ? `GitHub CLI as ${ghUser}` : Bun.which("gh") ? "not signed in" : "GitHub CLI (gh) not installed"}`,
483
+ row("jira", "Jira", jira && `${jira}, ${jiraUrl}`),
484
+ row("confluence", "Confluence", confHow && `${confHow}, ${conf!.url}`),
485
+ row("testrail", "TestRail", cfg?.sources.testrail || process.env[n.testrail.url] ? source([n.testrail.email, n.testrail.token]) : null),
486
+ row("zephyr", "Zephyr Scale", source([n.zephyr.token])),
487
+ row("qtest", "qTest", cfg?.sources.qtest || process.env[n.qtest.url] ? source([n.qtest.token]) : null),
488
+ row("xray", "Xray Cloud", source([n.xray.id, n.xray.secret])),
489
+ ]
490
+ const extra = Object.keys(saved).filter((k) => k.startsWith("env:")).map((k) => k.slice(4))
491
+ if (extra.length) lines.push(`${"Other secrets".padEnd(14)}${extra.join(", ")}`)
492
+ lines.push("", `Secrets are kept in ${storeName()}. A variable exported in the terminal is used before a saved sign-in, and a saved sign-in before a .env file.`)
493
+ return lines
494
+ }
@@ -0,0 +1,170 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { forgeqeHome } from "../tools.ts"
5
+
6
+ /**
7
+ * Credentials saved by `forgeqe auth login`. Secrets (tokens, API keys, passwords) go to the operating system's
8
+ * credential store (Keychain, Credential Manager, Secret Service) under the service "forgeqe", one entry per
9
+ * environment variable name. Where there is no store (a headless Linux box without Secret Service), or with
10
+ * FORGEQE_SECRET_STORE=file, they go to ~/.forgeqe/credentials.json, readable only by the user.
11
+ * ~/.forgeqe/auth.json says what is saved: per system, the non-secret values (URLs, emails) and the secret names.
12
+ */
13
+
14
+ const SERVICE = "forgeqe"
15
+ /** The service before ForgeQE was renamed from WebPilot: sign-ins saved under it are still read. */
16
+ const LEGACY_SERVICE = "webpilot"
17
+
18
+ export type Via = "token" | "azcli" | "gh"
19
+
20
+ export type SavedSystem = {
21
+ via: Via
22
+ /** Non-secret values by environment variable name (site URLs, emails, project ids). */
23
+ env: Record<string, string>
24
+ /** Environment variable names whose values are in the secret store. */
25
+ secrets: string[]
26
+ /** Who the credentials belong to, as the system reported it when they were checked. */
27
+ account?: string
28
+ savedAt: string
29
+ }
30
+
31
+ export type AuthIndex = { systems: Record<string, SavedSystem> }
32
+
33
+ const indexFile = () => path.join(forgeqeHome(), "auth.json")
34
+ const fileStore = () => path.join(forgeqeHome(), "credentials.json")
35
+
36
+ export function readIndex(): AuthIndex {
37
+ try {
38
+ const data = JSON.parse(readFileSync(indexFile(), "utf8")) as AuthIndex
39
+ return { systems: data?.systems && typeof data.systems === "object" ? data.systems : {} }
40
+ } catch {
41
+ return { systems: {} }
42
+ }
43
+ }
44
+
45
+ function writePrivate(file: string, data: unknown) {
46
+ mkdirSync(path.dirname(file), { recursive: true })
47
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 })
48
+ try {
49
+ chmodSync(file, 0o600)
50
+ } catch {}
51
+ }
52
+
53
+ function writeIndex(index: AuthIndex) {
54
+ writePrivate(indexFile(), index)
55
+ }
56
+
57
+ function readFileSecrets(): Record<string, string> {
58
+ try {
59
+ return JSON.parse(readFileSync(fileStore(), "utf8")) as Record<string, string>
60
+ } catch {
61
+ return {}
62
+ }
63
+ }
64
+
65
+ const useFile = () => /^file$/i.test(process.env.FORGEQE_SECRET_STORE || "") || typeof Bun.secrets?.get !== "function"
66
+
67
+ /** Where secrets are kept on this machine, for status lines. */
68
+ export function storeName(): string {
69
+ if (useFile()) return fileStore()
70
+ return process.platform === "darwin" ? "the macOS Keychain" : process.platform === "win32" ? "Windows Credential Manager" : "the Secret Service keyring"
71
+ }
72
+
73
+ export async function getSecret(name: string): Promise<string | null> {
74
+ if (!useFile()) {
75
+ try {
76
+ const value = (await Bun.secrets.get({ service: SERVICE, name })) || (await Bun.secrets.get({ service: LEGACY_SERVICE, name }))
77
+ if (value) return value
78
+ } catch {
79
+ // No usable OS store: fall through to the file.
80
+ }
81
+ }
82
+ return readFileSecrets()[name] ?? null
83
+ }
84
+
85
+ /** Saves to the OS store, or to the private file when there is none. Returns where it went. */
86
+ export async function setSecret(name: string, value: string): Promise<string> {
87
+ if (!useFile()) {
88
+ try {
89
+ await Bun.secrets.set({ service: SERVICE, name, value })
90
+ return storeName()
91
+ } catch {
92
+ // No usable OS store: use the file.
93
+ }
94
+ }
95
+ writePrivate(fileStore(), { ...readFileSecrets(), [name]: value })
96
+ return fileStore()
97
+ }
98
+
99
+ export async function deleteSecret(name: string): Promise<void> {
100
+ if (!useFile()) {
101
+ for (const service of [SERVICE, LEGACY_SERVICE]) {
102
+ try {
103
+ await Bun.secrets.delete({ service, name })
104
+ } catch {}
105
+ }
106
+ }
107
+ const all = readFileSecrets()
108
+ if (name in all) {
109
+ delete all[name]
110
+ writePrivate(fileStore(), all)
111
+ }
112
+ }
113
+
114
+ /** Record a system's credentials: secrets to the store, the rest to auth.json. Replaces what was saved for it. */
115
+ export async function saveSystem(id: string, rec: { via: Via; env: Record<string, string>; secrets: Record<string, string>; account?: string }): Promise<string> {
116
+ const index = readIndex()
117
+ const old = index.systems[id]
118
+ for (const name of old?.secrets || []) if (!(name in rec.secrets)) await deleteSecret(name)
119
+ let where = storeName()
120
+ for (const [name, value] of Object.entries(rec.secrets)) where = await setSecret(name, value)
121
+ index.systems[id] = { via: rec.via, env: rec.env, secrets: Object.keys(rec.secrets), ...(rec.account ? { account: rec.account } : {}), savedAt: new Date().toISOString() }
122
+ writeIndex(index)
123
+ return where
124
+ }
125
+
126
+ /** Forget a system: its secrets and its entry. Secrets another system still uses (Jira and Confluence) stay. */
127
+ export async function forgetSystem(id: string): Promise<boolean> {
128
+ const index = readIndex()
129
+ const old = index.systems[id]
130
+ if (!old) return false
131
+ delete index.systems[id]
132
+ const kept = new Set(Object.values(index.systems).flatMap((s) => s.secrets))
133
+ for (const name of old.secrets) if (!kept.has(name)) await deleteSecret(name)
134
+ writeIndex(index)
135
+ return true
136
+ }
137
+
138
+ /** Names whose values came from saved credentials in this process, for status. */
139
+ export const fromSaved = new Set<string>()
140
+ /** The secret ones among them: hidden from the shell commands agents run. */
141
+ export const savedSecrets = new Set<string>()
142
+
143
+ /**
144
+ * Put saved credentials into process.env, under the names the config reads, for every name the environment does not
145
+ * set already. Runs before .env files are read, so the order is: this terminal's environment, then saved sign-ins,
146
+ * then .env files.
147
+ */
148
+ export async function applySavedCredentials(env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
149
+ if (/^(0|off|false|no)$/i.test(env.FORGEQE_SAVED_AUTH || "") || !existsSync(indexFile())) return []
150
+ const applied: string[] = []
151
+ for (const sys of Object.values(readIndex().systems)) {
152
+ for (const [name, value] of Object.entries(sys.env || {})) {
153
+ if (env[name] === undefined && value) {
154
+ env[name] = value
155
+ applied.push(name)
156
+ }
157
+ }
158
+ for (const name of sys.secrets || []) {
159
+ if (env[name] !== undefined) continue
160
+ const value = await getSecret(name)
161
+ if (value) {
162
+ env[name] = value
163
+ applied.push(name)
164
+ savedSecrets.add(name)
165
+ }
166
+ }
167
+ }
168
+ for (const n of applied) fromSaved.add(n)
169
+ return applied
170
+ }