@capagents/forgeqe 0.0.0-stage → 1.0.0
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 +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/init.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import { forgeqeHome, projectDir } from "./tools.ts"
|
|
7
|
+
|
|
8
|
+
import { userConfigDir } from "./config.ts"
|
|
9
|
+
import { KNOWLEDGE_TEMPLATES } from "./knowledge.ts"
|
|
10
|
+
import { copyManaged } from "./managed.ts"
|
|
11
|
+
import { PROMPTS_DIR } from "./prompts.ts"
|
|
12
|
+
import { PREBUILT_DIR } from "./skills/registry.ts"
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(import.meta.dir, "..")
|
|
15
|
+
|
|
16
|
+
export function initWorkspace(
|
|
17
|
+
directory: string,
|
|
18
|
+
opts: { force?: boolean; profile?: string },
|
|
19
|
+
): { created: string[]; notes: string[] } {
|
|
20
|
+
mkdirSync(directory, { recursive: true })
|
|
21
|
+
const created: string[] = []
|
|
22
|
+
const notes: string[] = []
|
|
23
|
+
const profile = opts.profile
|
|
24
|
+
|
|
25
|
+
const pairs: Array<[string, string, (body: string) => string]> = [
|
|
26
|
+
["llms.example.json", "llms.json", (body) => body],
|
|
27
|
+
[
|
|
28
|
+
"forgeqe.example.yaml",
|
|
29
|
+
"forgeqe.yaml",
|
|
30
|
+
(body) => (profile ? body.replace(/^ {2}# active: .*$/m, ` active: ${profile}`) : body),
|
|
31
|
+
],
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const globalModels = path.join(userConfigDir(), "llms.json")
|
|
35
|
+
for (const [srcName, destName, map] of pairs) {
|
|
36
|
+
const src = path.join(ROOT, srcName)
|
|
37
|
+
const dest = path.join(directory, destName)
|
|
38
|
+
if (destName === "llms.json" && !existsSync(dest) && existsSync(globalModels) && path.resolve(directory) !== userConfigDir()) {
|
|
39
|
+
notes.push(`Using your profiles from ${globalModels}; no project llms.json written.`)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (!existsSync(src)) {
|
|
43
|
+
notes.push(`Template missing: ${src}`)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (existsSync(dest) && !opts.force) {
|
|
47
|
+
notes.push(`Kept existing ${dest}`)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (destName === "forgeqe.yaml") {
|
|
51
|
+
writeFileSync(dest, map(readFileSync(src, "utf8")), "utf8")
|
|
52
|
+
} else {
|
|
53
|
+
writeFileSync(dest, map(readFileSync(src, "utf8")), "utf8")
|
|
54
|
+
}
|
|
55
|
+
created.push(dest)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const home = path.resolve(directory) === userConfigDir()
|
|
59
|
+
if (!home) {
|
|
60
|
+
const ignored = ignoreReports(directory)
|
|
61
|
+
if (ignored) notes.push(ignored)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Prompts, skills, and product knowledge templates, to read and change: .forgeqe/ in a project, ~/.forgeqe for the user.
|
|
65
|
+
const root = home ? forgeqeHome() : projectDir(directory)
|
|
66
|
+
const shown = (p: string) => (home ? p : path.relative(directory, p) || p)
|
|
67
|
+
for (const { what, src, dest, keys } of [
|
|
68
|
+
{ what: "prompts", src: PROMPTS_DIR, dest: path.join(root, "prompts"), keys: entries(PROMPTS_DIR, (e) => e.endsWith(".md")) },
|
|
69
|
+
{ what: "skills", src: PREBUILT_DIR, dest: path.join(root, "skills"), keys: entries(PREBUILT_DIR, (e) => existsSync(path.join(PREBUILT_DIR, e, "SKILL.md"))) },
|
|
70
|
+
{ what: "product knowledge templates", src: KNOWLEDGE_TEMPLATES, dest: path.join(root, "knowledge"), keys: entries(KNOWLEDGE_TEMPLATES, (e) => /\.(md|txt)$/.test(e)) },
|
|
71
|
+
]) {
|
|
72
|
+
const { copied, kept } = copyManaged(src, dest, keys, { force: opts.force })
|
|
73
|
+
if (copied.length) created.push(`${dest}/ (${copied.length} ${what})`)
|
|
74
|
+
if (kept.length) notes.push(`Kept ${kept.length} existing ${what} in ${shown(dest)}${opts.force ? " (edited ones are never overwritten)" : ""}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const known = ["azure-gpt4o", "azure-gpt4o-entra", "openai-gpt4o", "ollama-llama", "compatible"]
|
|
78
|
+
if (profile && !known.includes(profile)) {
|
|
79
|
+
notes.push(`Profile "${profile}" is not in the template llms.json. Add it before running.`)
|
|
80
|
+
}
|
|
81
|
+
return { created, notes }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function entries(dir: string, keep: (name: string) => boolean): string[] {
|
|
85
|
+
try {
|
|
86
|
+
return readdirSync(dir).filter((e) => !e.startsWith(".") && keep(e)).sort()
|
|
87
|
+
} catch {
|
|
88
|
+
return []
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Adds .forgeqe/reports/ (.webpilot/reports/ in a project from before the rename) to the folder's .gitignore unless git already ignores it; the note to show, or null. */
|
|
93
|
+
export function ignoreReports(directory: string): string | null {
|
|
94
|
+
const REPORTS_IGNORE = `${path.basename(projectDir(directory))}/reports/`
|
|
95
|
+
const check = spawnSync("git", ["check-ignore", "-q", `${REPORTS_IGNORE}run`], { cwd: directory, stdio: "ignore" })
|
|
96
|
+
if (check.status === 0) return null
|
|
97
|
+
const file = path.join(directory, ".gitignore")
|
|
98
|
+
const body = existsSync(file) ? readFileSync(file, "utf8") : ""
|
|
99
|
+
if (body.split(/\r?\n/).some((l) => l.trim() === REPORTS_IGNORE)) return null
|
|
100
|
+
appendFileSync(file, `${body && !body.endsWith("\n") ? "\n" : ""}${body ? "\n" : ""}# ForgeQE run reports (steps, screenshots, traces)\n${REPORTS_IGNORE}\n`)
|
|
101
|
+
return `Added ${REPORTS_IGNORE} to ${file}`
|
|
102
|
+
}
|
package/src/knowledge.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { copyState, MANIFEST } from "./managed.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Product knowledge: markdown or text files in .forgeqe/knowledge/ (knowledge.dir) about the product: what it does,
|
|
8
|
+
* roles, glossary, business rules, test data, environments. ForgeQE's agent sees the list and reads what a request
|
|
9
|
+
* needs; planners and the browser agent get the sections that match their task. A template copy nobody filled in
|
|
10
|
+
* (unchanged since forgeqe init) is left out.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const KNOWLEDGE_TEMPLATES = path.resolve(import.meta.dir, "..", "knowledge")
|
|
14
|
+
const MAX_FILE = 200_000
|
|
15
|
+
const STOP = new Set(
|
|
16
|
+
"the a an and or of to in on for with is are be should must can will user users when then that this from into able page shown show shows display displayed after before your their there what which while about have has not".split(" "),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export type KnowledgeFile = { file: string; rel: string; title: string; text: string }
|
|
20
|
+
export type Section = { rel: string; heading: string; text: string }
|
|
21
|
+
|
|
22
|
+
function walk(dir: string, at = ""): string[] {
|
|
23
|
+
let entries: string[]
|
|
24
|
+
try {
|
|
25
|
+
entries = readdirSync(path.join(dir, at))
|
|
26
|
+
} catch {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
const out: string[] = []
|
|
30
|
+
for (const e of entries.sort()) {
|
|
31
|
+
if (e.startsWith(".") || e === MANIFEST) continue
|
|
32
|
+
const rel = path.join(at, e)
|
|
33
|
+
const full = path.join(dir, rel)
|
|
34
|
+
if (statSync(full).isDirectory()) out.push(...walk(dir, rel))
|
|
35
|
+
else if (/\.(md|txt)$/i.test(e) && e.toLowerCase() !== "readme.md") out.push(rel)
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The knowledge files that have content of their own (not an untouched template). */
|
|
41
|
+
export function readKnowledge(dir: string | undefined): KnowledgeFile[] {
|
|
42
|
+
if (!dir || !existsSync(dir)) return []
|
|
43
|
+
return walk(dir)
|
|
44
|
+
.filter((rel) => copyState(dir, rel) !== "unchanged" && statSync(path.join(dir, rel)).size <= MAX_FILE)
|
|
45
|
+
.map((rel) => {
|
|
46
|
+
const text = readFileSync(path.join(dir, rel), "utf8").replace(/<!--[\s\S]*?-->/g, "").trim()
|
|
47
|
+
const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() || rel.replace(/\.(md|txt)$/i, "")
|
|
48
|
+
return { file: path.join(dir, rel), rel: rel.split(path.sep).join("/"), title, text }
|
|
49
|
+
})
|
|
50
|
+
.filter((k) => k.text.replace(/^#.*$/gm, "").trim().length > 0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Each file split at its ## (or deeper) headings, so a task gets only the parts it needs. */
|
|
54
|
+
export function sections(files: KnowledgeFile[]): Section[] {
|
|
55
|
+
const out: Section[] = []
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
const parts = f.text.split(/\n(?=#{2,4}\s)/)
|
|
58
|
+
for (const p of parts) {
|
|
59
|
+
const heading = p.match(/^#{1,4}\s+(.+)$/m)?.[1]?.trim() || f.title
|
|
60
|
+
const text = p.trim()
|
|
61
|
+
if (text.replace(/^#.*$/gm, "").trim()) out.push({ rel: f.rel, heading: heading === f.title ? f.title : `${f.title} › ${heading}`, text })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A word without its plural or past ending, so "addresses" matches "address" and "refused" matches "refuse". */
|
|
68
|
+
function stem(w: string): string {
|
|
69
|
+
if (w.endsWith("sses")) return w.slice(0, -2)
|
|
70
|
+
if (w.endsWith("ies") && w.length > 4) return `${w.slice(0, -3)}y`
|
|
71
|
+
if (w.endsWith("ing") && w.length > 5) return w.slice(0, -3)
|
|
72
|
+
if (w.endsWith("ed") && w.length > 4) return w.slice(0, -2)
|
|
73
|
+
if (w.endsWith("s") && !w.endsWith("ss") && w.length > 4) return w.slice(0, -1)
|
|
74
|
+
return w
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const words = (s: string) => new Set(s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !STOP.has(w)).map(stem))
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The sections that share the most words with the task, most relevant first, up to `maxChars`. `minHits` leaves out
|
|
81
|
+
* sections that share fewer words (the browser agent asks for 2, so only clearly related parts reach every step).
|
|
82
|
+
*/
|
|
83
|
+
export function relevantKnowledge(files: KnowledgeFile[], task: string, maxChars: number, minHits = 1): Section[] {
|
|
84
|
+
const want = words(task)
|
|
85
|
+
if (!want.size || maxChars <= 0) return []
|
|
86
|
+
const scored = sections(files)
|
|
87
|
+
.map((s) => {
|
|
88
|
+
const have = words(`${s.heading} ${s.text}`)
|
|
89
|
+
let hits = 0
|
|
90
|
+
for (const w of want) if (have.has(w)) hits++
|
|
91
|
+
return { s, hits }
|
|
92
|
+
})
|
|
93
|
+
.filter((x) => x.hits >= minHits)
|
|
94
|
+
.sort((a, b) => b.hits - a.hits)
|
|
95
|
+
const out: Section[] = []
|
|
96
|
+
let used = 0
|
|
97
|
+
for (const { s } of scored) {
|
|
98
|
+
if (used + s.text.length > maxChars) continue
|
|
99
|
+
out.push(s)
|
|
100
|
+
used += s.text.length
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Sections as a prompt block, or "" when there are none. */
|
|
106
|
+
export function knowledgeBlock(found: Section[], title = "Product knowledge"): string {
|
|
107
|
+
if (!found.length) return ""
|
|
108
|
+
return [`## ${title}`, "", "From the team's notes about the product (.forgeqe/knowledge). Use them where they apply; the live application wins where they disagree.", "", ...found.map((s) => `### ${s.heading} (${s.rel})\n${s.text.replace(/^#{1,4}\s+.*\n?/, "").trim()}`)].join("\n")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One line per file, for ForgeQE's agent to pick what to read. */
|
|
112
|
+
export function knowledgeIndex(files: KnowledgeFile[]): string[] {
|
|
113
|
+
return files.map((f) => `- ${f.file}: ${f.title} (${Math.max(1, Math.round(f.text.length / 1000))} KB)`)
|
|
114
|
+
}
|
package/src/legacyEnv.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ForgeQE was called WebPilot: each WEBPILOT_* variable still works as its FORGEQE_* name, which wins when both are set.
|
|
3
|
+
* The entry point imports this first, before anything reads the environment.
|
|
4
|
+
*/
|
|
5
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
6
|
+
if (!key.startsWith("WEBPILOT_") || value === undefined) continue
|
|
7
|
+
const name = `FORGEQE_${key.slice("WEBPILOT_".length)}`
|
|
8
|
+
if (process.env[name] === undefined) process.env[name] = value
|
|
9
|
+
}
|
package/src/llm.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Price } from "./usage.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One entry of llms.json, driven by the ForgeQE engine. `none` stands in when no profile is set up;
|
|
5
|
+
* `mock` is the offline test double, chosen only with --profile mock or WP_LLM_PROFILE=mock.
|
|
6
|
+
*/
|
|
7
|
+
export type LlmProfile = {
|
|
8
|
+
provider: "none" | "mock" | "openai" | "azure" | "ollama" | "openai_compatible"
|
|
9
|
+
model: string
|
|
10
|
+
endpoint?: string
|
|
11
|
+
deployment?: string
|
|
12
|
+
apiKey?: string
|
|
13
|
+
apiKeyEnv?: string
|
|
14
|
+
/** How ForgeQE signs in to the model: an API key (the default), or Microsoft Entra ID with no key at all. */
|
|
15
|
+
auth?: "key" | "entra"
|
|
16
|
+
/** auth: entra — the client id of the user-assigned managed identity to use; else the machine's default identity. */
|
|
17
|
+
managedIdentityClientId?: string
|
|
18
|
+
/** auth: entra — the token's scope; default Azure OpenAI's. A gateway such as API Management may want its own. */
|
|
19
|
+
tokenScope?: string
|
|
20
|
+
apiVersion?: string
|
|
21
|
+
temperature?: number
|
|
22
|
+
/** Price per million tokens, for the cost shown in reports: llms.json's, else the model's list price. */
|
|
23
|
+
price?: Price
|
|
24
|
+
/** llms.json's price differs from the model's list price: what to tell the user. */
|
|
25
|
+
priceWarning?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Why this profile has no API key in this environment, or null. Checked before any request, so a missing key
|
|
30
|
+
* names its variable instead of coming back as the provider's "access denied".
|
|
31
|
+
*/
|
|
32
|
+
export const NO_MODEL_SET_UP =
|
|
33
|
+
"No model is set up yet. Add a profile with your provider and key to llms.json (forgeqe init writes one to fill in), " +
|
|
34
|
+
"then pick it with llm.active in forgeqe.yaml or Tab in the shell. forgeqe models lists the profiles ForgeQE sees."
|
|
35
|
+
|
|
36
|
+
/** Why ForgeQE's agent or the coding agent has no model to run on (code.model can supply one), or null. */
|
|
37
|
+
export function noAgentModel(p: LlmProfile, codeModel: string | undefined, what: string): string | null {
|
|
38
|
+
if (codeModel) return null
|
|
39
|
+
if (p.provider === "none") return NO_MODEL_SET_UP
|
|
40
|
+
if (p.provider === "mock") return `The mock profile has no model for ${what}. Pick a live profile, or set code.model.`
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The providers that can sign in with Microsoft Entra ID instead of a key. */
|
|
45
|
+
export const ENTRA_PROVIDERS: LlmProfile["provider"][] = ["azure", "openai_compatible"]
|
|
46
|
+
|
|
47
|
+
export function missingKey(p: LlmProfile): string | null {
|
|
48
|
+
if (p.provider === "none") return NO_MODEL_SET_UP
|
|
49
|
+
if (p.auth === "entra") {
|
|
50
|
+
// The credential is found when the first request is made: managed identity, workload identity or az login.
|
|
51
|
+
return ENTRA_PROVIDERS.includes(p.provider) ? null : `auth: entra works with the azure and openai_compatible providers, not ${p.provider} (profile for ${p.model}).`
|
|
52
|
+
}
|
|
53
|
+
if (p.provider !== "azure" && p.provider !== "openai") return null
|
|
54
|
+
if (p.apiKey?.trim()) return null
|
|
55
|
+
if (!p.apiKeyEnv) return `The ${p.provider} profile for ${p.model} has no api_key_env in llms.json, so it has no API key.`
|
|
56
|
+
if (process.env[p.apiKeyEnv]?.trim()) return null
|
|
57
|
+
return (
|
|
58
|
+
`The ${p.provider} profile for ${p.model} reads its API key from ${p.apiKeyEnv}, which is not set in this terminal. ` +
|
|
59
|
+
`Set ${p.apiKeyEnv} (or put it in a .env file next to forgeqe.yaml), or change api_key_env in llms.json to the variable that holds your key.`
|
|
60
|
+
)
|
|
61
|
+
}
|
package/src/locale.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
function envLocale(): string | undefined {
|
|
4
|
+
const raw = process.env.LC_ALL || process.env.LC_MONETARY || process.env.LANG || ""
|
|
5
|
+
if (!raw || raw === "C" || raw === "POSIX" || raw.startsWith("C.")) return undefined
|
|
6
|
+
return raw.split(".")[0].replaceAll("_", "-")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let cached: string | undefined
|
|
10
|
+
|
|
11
|
+
/** The language this machine is set to. A C/POSIX shell locale is ignored. */
|
|
12
|
+
export function systemLocale(): string {
|
|
13
|
+
if (cached) return cached
|
|
14
|
+
const fromEnv = envLocale()
|
|
15
|
+
if (fromEnv) return (cached = fromEnv)
|
|
16
|
+
if (process.platform === "darwin") {
|
|
17
|
+
try {
|
|
18
|
+
const apple = execFileSync("defaults", ["read", "-g", "AppleLocale"], { encoding: "utf8" }).trim()
|
|
19
|
+
if (apple) return (cached = apple.replaceAll("_", "-"))
|
|
20
|
+
} catch {
|
|
21
|
+
/* defaults is missing */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return (cached = Intl.DateTimeFormat().resolvedOptions().locale || "en-US")
|
|
25
|
+
}
|
package/src/logins.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { existsSync, rmSync, statSync } from "node:fs"
|
|
3
|
+
import { createInterface } from "node:readline"
|
|
4
|
+
|
|
5
|
+
import { loginEnvNames } from "./childEnv.ts"
|
|
6
|
+
import type { AppConfig, LoginConfig } from "./config.ts"
|
|
7
|
+
import { loginPayload } from "./engine/bridge.ts"
|
|
8
|
+
import { engineEnv, ensureEngine } from "./engine/setup.ts"
|
|
9
|
+
|
|
10
|
+
export const LOGINS_USAGE = `Saved logins come from logins: in forgeqe.yaml, for example
|
|
11
|
+
logins:
|
|
12
|
+
shop:
|
|
13
|
+
url: https://staging.shop.example/login # the site it signs in to (or domains: [...])
|
|
14
|
+
username_env: SHOP_USER
|
|
15
|
+
password_env: SHOP_PASSWORD
|
|
16
|
+
totp_env: SHOP_TOTP_SECRET # optional: the authenticator secret (base32)
|
|
17
|
+
|
|
18
|
+
forgeqe login <name> [--url URL] sign in once by hand in a browser window; the session is reused by runs
|
|
19
|
+
forgeqe logins list logins and their saved sessions
|
|
20
|
+
forgeqe logins clear <name|--all> forget saved sessions`
|
|
21
|
+
|
|
22
|
+
/** Where `forgeqe login` opens the browser: --url, the login's url, or its first domain without wildcards. */
|
|
23
|
+
export function loginStartUrl(login: LoginConfig, url?: string): string {
|
|
24
|
+
const pick = url || login.url || login.domains.find((d) => !d.includes("*"))
|
|
25
|
+
if (!pick) throw new Error(`Login "${login.name}" has only wildcard domains; pass --url or set url: in forgeqe.yaml.`)
|
|
26
|
+
return /^[a-z]+:\/\//i.test(pick) ? pick : `https://${pick}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function describeLogins(cfg: AppConfig): string {
|
|
30
|
+
if (!cfg.logins.length) return `No saved logins.\n\n${LOGINS_USAGE}`
|
|
31
|
+
const lines = cfg.logins.map((l) => {
|
|
32
|
+
const envs = [l.usernameEnv, l.passwordEnv, l.totpEnv].filter((e): e is string => Boolean(e))
|
|
33
|
+
const missing = envs.filter((e) => !process.env[e])
|
|
34
|
+
const session = !l.sessionFile ? "not saved (save_session: false)" : existsSync(l.sessionFile) ? `saved ${statSync(l.sessionFile).mtime.toISOString().slice(0, 16).replace("T", " ")}` : "none yet"
|
|
35
|
+
return [
|
|
36
|
+
`${l.name} ${l.domains.join(", ")}`,
|
|
37
|
+
` credentials ${envs.length ? envs.join(", ") : "none (session only)"}${missing.length ? ` (not set: ${missing.join(", ")})` : ""}`,
|
|
38
|
+
` session ${session}${l.sessionFile ? ` ${l.sessionFile}` : ""}`,
|
|
39
|
+
].join("\n")
|
|
40
|
+
})
|
|
41
|
+
return lines.join("\n\n")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearLogins(cfg: AppConfig, name: string | undefined, all: boolean): string {
|
|
45
|
+
const picked = all ? cfg.logins : cfg.logins.filter((l) => l.name === name)
|
|
46
|
+
if (!picked.length) throw new Error(name ? `No login "${name}" in forgeqe.yaml.` : "Name a login, or pass --all.")
|
|
47
|
+
const removed = picked.filter((l) => l.sessionFile && existsSync(l.sessionFile))
|
|
48
|
+
for (const l of removed) rmSync(l.sessionFile!, { force: true })
|
|
49
|
+
return removed.length ? `Forgot the saved session for ${removed.map((l) => l.name).join(", ")}.` : "No saved sessions to forget."
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SignInResult = { status: "saved" | "cancelled" | "failed"; text: string; sessionFile?: string }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Open a visible browser on the login's page with any saved session, let the user sign in (SSO, captcha, security
|
|
56
|
+
* keys: whatever the site asks), and save cookies and local storage once `wait` resolves true (false cancels).
|
|
57
|
+
*/
|
|
58
|
+
export async function signIn(
|
|
59
|
+
cfg: AppConfig,
|
|
60
|
+
name: string | undefined,
|
|
61
|
+
io: { url?: string; log?: (text: string) => void; wait: (prompt: string) => Promise<boolean>; signal?: AbortSignal },
|
|
62
|
+
): Promise<SignInResult> {
|
|
63
|
+
const login = cfg.logins.find((l) => l.name === name)
|
|
64
|
+
if (!login) throw new Error(`${name ? `No login "${name}" in forgeqe.yaml.` : "Name the login to sign in to."}\n\n${LOGINS_USAGE}`)
|
|
65
|
+
if (!login.sessionFile) throw new Error(`Login "${login.name}" has save_session: false, so there is nowhere to keep the session.`)
|
|
66
|
+
const log = io.log || (() => {})
|
|
67
|
+
const start = loginStartUrl(login, io.url)
|
|
68
|
+
const python = await ensureEngine(log)
|
|
69
|
+
const child = spawn(python, ["-m", "forgeqe_engine", "login"], { env: engineEnv(undefined, loginEnvNames([login])), stdio: ["pipe", "pipe", "pipe"] })
|
|
70
|
+
const stderrTail: string[] = []
|
|
71
|
+
child.stderr.setEncoding("utf8")
|
|
72
|
+
child.stderr.on("data", (chunk: string) => {
|
|
73
|
+
stderrTail.push(...chunk.split("\n").filter(Boolean))
|
|
74
|
+
stderrTail.splice(0, Math.max(0, stderrTail.length - 20))
|
|
75
|
+
})
|
|
76
|
+
child.stdin.write(`${JSON.stringify({ url: start, logins: loginPayload([login]), overlay: cfg.browser.overlay })}\n`)
|
|
77
|
+
|
|
78
|
+
const send = (cmd: string) => {
|
|
79
|
+
if (!child.stdin.destroyed) child.stdin.write(`${cmd}\n`)
|
|
80
|
+
}
|
|
81
|
+
let interrupted = false
|
|
82
|
+
const onAbort = () => {
|
|
83
|
+
interrupted = true
|
|
84
|
+
send("stop")
|
|
85
|
+
}
|
|
86
|
+
io.signal?.addEventListener("abort", onAbort, { once: true })
|
|
87
|
+
|
|
88
|
+
let result: { success?: boolean; error?: string; summary?: string } | undefined
|
|
89
|
+
const events = createInterface({ input: child.stdout })
|
|
90
|
+
events.on("line", (line) => {
|
|
91
|
+
let e: { type?: string; text?: string; result?: typeof result }
|
|
92
|
+
try {
|
|
93
|
+
e = JSON.parse(line)
|
|
94
|
+
} catch {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (e.type === "log" && e.text) {
|
|
98
|
+
log(e.text)
|
|
99
|
+
if (e.text.startsWith("Browser open")) {
|
|
100
|
+
void io.wait(`Sign in to ${login.name} in the browser window, then save the session.`).then((ok) => {
|
|
101
|
+
if (!ok) interrupted = true
|
|
102
|
+
send(ok ? "save" : "stop")
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
} else if (e.type === "done") result = e.result
|
|
106
|
+
})
|
|
107
|
+
const code = await new Promise<number | null>((resolve) => child.on("close", resolve))
|
|
108
|
+
io.signal?.removeEventListener("abort", onAbort)
|
|
109
|
+
if (result?.success) {
|
|
110
|
+
return {
|
|
111
|
+
status: "saved",
|
|
112
|
+
sessionFile: login.sessionFile,
|
|
113
|
+
text: `${result.summary}: ${login.sessionFile}\nRuns on these domains start signed in. The file holds your session cookies; keep it out of git.`,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (result?.error === "cancelled" || (!result && interrupted)) return { status: "cancelled", text: "Cancelled; nothing was saved." }
|
|
117
|
+
return { status: "failed", text: result?.error || `The browser exited (${code}).${stderrTail.length ? `\n${stderrTail.slice(-8).join("\n")}` : ""}` }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `forgeqe login <name>`: sign in by hand, pressing Enter in the terminal to save (Ctrl+C cancels). */
|
|
121
|
+
export async function interactiveLogin(cfg: AppConfig, name: string | undefined, url?: string): Promise<number> {
|
|
122
|
+
const keys = createInterface({ input: process.stdin })
|
|
123
|
+
const controller = new AbortController()
|
|
124
|
+
const onSigint = () => controller.abort()
|
|
125
|
+
process.on("SIGINT", onSigint)
|
|
126
|
+
try {
|
|
127
|
+
const r = await signIn(cfg, name, {
|
|
128
|
+
url,
|
|
129
|
+
signal: controller.signal,
|
|
130
|
+
log: (text) => console.log(text),
|
|
131
|
+
wait: (prompt) =>
|
|
132
|
+
new Promise((resolve) => {
|
|
133
|
+
console.log(`${prompt} Press Enter here to save (Ctrl+C cancels).`)
|
|
134
|
+
keys.once("line", () => resolve(true))
|
|
135
|
+
controller.signal.addEventListener("abort", () => resolve(false), { once: true })
|
|
136
|
+
}),
|
|
137
|
+
})
|
|
138
|
+
if (r.status === "failed") console.error(r.text)
|
|
139
|
+
else console.log(r.text)
|
|
140
|
+
return r.status === "saved" ? 0 : r.status === "cancelled" ? 2 : 1
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error((err as Error).message)
|
|
143
|
+
return 1
|
|
144
|
+
} finally {
|
|
145
|
+
process.off("SIGINT", onSigint)
|
|
146
|
+
keys.close()
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/managed.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Copies ForgeQE puts into a project (prompts, skills, product knowledge templates) and a manifest of what each
|
|
7
|
+
* looked like when copied. A copy nobody changed is still ForgeQE's: the built-in one is used, so it keeps getting
|
|
8
|
+
* ForgeQE's improvements. A copy someone edited is theirs and wins; when the built-in changed after the copy was made,
|
|
9
|
+
* it is reported as based on an older version.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const MANIFEST = ".forgeqe-manifest.json"
|
|
13
|
+
/** The manifest's name before ForgeQE was renamed from WebPilot; still read. */
|
|
14
|
+
const LEGACY_MANIFEST = ".webpilot-manifest.json"
|
|
15
|
+
|
|
16
|
+
type Entry = { sha: string; at: string }
|
|
17
|
+
type Manifest = { version: 1; note: string; entries: Record<string, Entry> }
|
|
18
|
+
|
|
19
|
+
const NOTE = "Written by forgeqe init: the hash of each file or folder as it was copied. A copy you have not changed follows ForgeQE's built-in version; once you edit it, yours is used. Keep this file."
|
|
20
|
+
|
|
21
|
+
export const sha = (data: string | Buffer) => createHash("sha256").update(data).digest("hex").slice(0, 16)
|
|
22
|
+
|
|
23
|
+
function files(dir: string, at = ""): string[] {
|
|
24
|
+
const out: string[] = []
|
|
25
|
+
for (const e of readdirSync(path.join(dir, at)).sort()) {
|
|
26
|
+
if (e === MANIFEST || e === LEGACY_MANIFEST || e.startsWith(".")) continue
|
|
27
|
+
const rel = path.join(at, e)
|
|
28
|
+
if (statSync(path.join(dir, rel)).isDirectory()) out.push(...files(dir, rel))
|
|
29
|
+
else out.push(rel)
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A file's hash, or a folder's (every file in it, by relative path and content). */
|
|
35
|
+
export function hashOf(p: string): string {
|
|
36
|
+
if (!existsSync(p)) return ""
|
|
37
|
+
if (!statSync(p).isDirectory()) return sha(readFileSync(p).toString("utf8").replace(/\r\n/g, "\n"))
|
|
38
|
+
return sha(files(p).map((f) => `${f.split(path.sep).join("/")}\n${readFileSync(path.join(p, f)).toString("utf8").replace(/\r\n/g, "\n")}`).join("\n\0"))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readManifest(dir: string): Manifest {
|
|
42
|
+
try {
|
|
43
|
+
const file = existsSync(path.join(dir, MANIFEST)) ? MANIFEST : LEGACY_MANIFEST
|
|
44
|
+
const m = JSON.parse(readFileSync(path.join(dir, file), "utf8")) as Manifest
|
|
45
|
+
return { version: 1, note: NOTE, entries: m?.entries && typeof m.entries === "object" ? m.entries : {} }
|
|
46
|
+
} catch {
|
|
47
|
+
return { version: 1, note: NOTE, entries: {} }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How a copy in `dir` stands: missing, unchanged since it was copied (ForgeQE's), or edited (the user's). */
|
|
52
|
+
export function copyState(dir: string, key: string): "missing" | "unchanged" | "edited" | "own" {
|
|
53
|
+
const p = path.join(dir, key)
|
|
54
|
+
if (!existsSync(p)) return "missing"
|
|
55
|
+
const entry = readManifest(dir).entries[key]
|
|
56
|
+
if (!entry) return "own"
|
|
57
|
+
return hashOf(p) === entry.sha ? "unchanged" : "edited"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** An edited copy whose built-in changed after it was copied: the user's version is based on an older one. */
|
|
61
|
+
export function basedOnOlder(dir: string, key: string, builtin: string): boolean {
|
|
62
|
+
const entry = readManifest(dir).entries[key]
|
|
63
|
+
return Boolean(entry) && copyState(dir, key) === "edited" && hashOf(builtin) !== entry.sha
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function copyTree(src: string, dest: string): void {
|
|
67
|
+
if (!statSync(src).isDirectory()) {
|
|
68
|
+
mkdirSync(path.dirname(dest), { recursive: true })
|
|
69
|
+
copyFileSync(src, dest)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
for (const f of files(src)) {
|
|
73
|
+
mkdirSync(path.dirname(path.join(dest, f)), { recursive: true })
|
|
74
|
+
copyFileSync(path.join(src, f), path.join(dest, f))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Copy each of `keys` (files or folders under `srcRoot`) into `destRoot`, and record them in its manifest. What is
|
|
80
|
+
* already there is kept unless `force`, and then only when it is an unchanged copy: an edited one is never overwritten.
|
|
81
|
+
*/
|
|
82
|
+
export function copyManaged(srcRoot: string, destRoot: string, keys: string[], opts: { force?: boolean } = {}): { copied: string[]; kept: string[] } {
|
|
83
|
+
mkdirSync(destRoot, { recursive: true })
|
|
84
|
+
const m = readManifest(destRoot)
|
|
85
|
+
const copied: string[] = []
|
|
86
|
+
const kept: string[] = []
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
const src = path.join(srcRoot, key)
|
|
89
|
+
if (!existsSync(src)) continue
|
|
90
|
+
const state = copyState(destRoot, key)
|
|
91
|
+
if (state !== "missing" && !(opts.force && state === "unchanged")) {
|
|
92
|
+
kept.push(key)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
copyTree(src, path.join(destRoot, key))
|
|
96
|
+
m.entries[key] = { sha: hashOf(src), at: new Date().toISOString() }
|
|
97
|
+
copied.push(key)
|
|
98
|
+
}
|
|
99
|
+
if (copied.length) writeFileSync(path.join(destRoot, MANIFEST), `${JSON.stringify({ version: 1, note: NOTE, entries: Object.fromEntries(Object.entries(m.entries).sort(([a], [b]) => a.localeCompare(b))) }, null, 2)}\n`)
|
|
100
|
+
return { copied, kept }
|
|
101
|
+
}
|