headlesscode 1.0.2
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/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
package/src/github/pr.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a pull request via the GitHub REST API using the installation token.
|
|
3
|
+
*
|
|
4
|
+
* `openPullRequest()` is the PR half of push-back: after `pushBranch()` has
|
|
5
|
+
* pushed a named branch (see src/github/push.ts), this opens a PR from that
|
|
6
|
+
* `head` branch into a `base` branch.
|
|
7
|
+
*
|
|
8
|
+
* `getPullRequestStatus()` is the read-back half used by orchestrator
|
|
9
|
+
* worktree cleanup (`src/orchestrator/merge-check.ts`): a squash/rebase PR
|
|
10
|
+
* merge creates a NEW commit whose hash is nothing on the source branch, so
|
|
11
|
+
* the local `merge-base --is-ancestor` check alone would wrongly report
|
|
12
|
+
* "not merged". GitHub's server-computed `.merged` boolean on
|
|
13
|
+
* `GET /repos/{owner}/{repo}/pulls/{number}` is the only authoritative
|
|
14
|
+
* "is this PR merged" answer for that workflow.
|
|
15
|
+
*
|
|
16
|
+
* The base branch is never hardcoded: when `base` is omitted it is resolved
|
|
17
|
+
* fresh from the repo API (`GET /repos/{owner}/{repo}` → `default_branch`),
|
|
18
|
+
* so the PR always targets the repo's REAL default branch unless the caller
|
|
19
|
+
* overrides it explicitly.
|
|
20
|
+
*
|
|
21
|
+
* Transport: plain authenticated `fetch` (Node >= 18) against the one REST
|
|
22
|
+
* endpoint needed, matching src/github/installations.ts — no new runtime
|
|
23
|
+
* dependency (this project deliberately avoids heavy SDKs; @octokit/rest is
|
|
24
|
+
* not even present). The token goes in an `Authorization: Bearer` header and
|
|
25
|
+
* is never logged.
|
|
26
|
+
*
|
|
27
|
+
* Failure honesty: a PR whose head→base has nothing to merge is a REAL GitHub
|
|
28
|
+
* API error (HTTP 422 "No commits between ..." / "The head and base branches
|
|
29
|
+
* are the same"). It is surfaced as a typed NoDiffError — never silently
|
|
30
|
+
* succeeded or retried.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The function that supplies a fresh installation access token. */
|
|
34
|
+
type TokenSupplier = () => Promise<string>
|
|
35
|
+
|
|
36
|
+
export interface OpenPrOptions {
|
|
37
|
+
installationId: number | string
|
|
38
|
+
owner: string
|
|
39
|
+
repo: string
|
|
40
|
+
/** Branch name pushed in step 1 (the PR's source). */
|
|
41
|
+
head: string
|
|
42
|
+
/**
|
|
43
|
+
* Target branch, e.g. "main"/"master". Omitted → resolved fresh from the
|
|
44
|
+
* repo API's default_branch (never hardcoded here).
|
|
45
|
+
*/
|
|
46
|
+
base?: string
|
|
47
|
+
title: string
|
|
48
|
+
body: string
|
|
49
|
+
getToken: TokenSupplier
|
|
50
|
+
/** API base URL override (tests/mocks; default api.github.com). */
|
|
51
|
+
baseUrl?: string
|
|
52
|
+
/** Injectable fetch for tests (default: global fetch). */
|
|
53
|
+
fetchImpl?: typeof fetch
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OpenPrResult {
|
|
57
|
+
number: number
|
|
58
|
+
/** The human-facing PR page URL (GitHub's `html_url`), not the API URL. */
|
|
59
|
+
url: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Non-2xx GitHub API response for one of the PR endpoints. */
|
|
63
|
+
export class OpenPrError extends Error {
|
|
64
|
+
readonly status: number
|
|
65
|
+
readonly url: string
|
|
66
|
+
constructor(status: number, message: string, url: string, options?: ErrorOptions) {
|
|
67
|
+
super(message, options)
|
|
68
|
+
this.name = "OpenPrError"
|
|
69
|
+
this.status = status
|
|
70
|
+
this.url = url
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The PR has nothing to merge (no commits between head and base, or head ===
|
|
75
|
+
* base) — GitHub's real "you cannot open this PR" case. */
|
|
76
|
+
export class NoDiffError extends OpenPrError {
|
|
77
|
+
constructor(status: number, message: string, url: string, options?: ErrorOptions) {
|
|
78
|
+
super(status, message, url, options)
|
|
79
|
+
this.name = "NoDiffError"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DEFAULT_GH_BASE_URL = "https://api.github.com"
|
|
84
|
+
const GH_API_VERSION = "2022-11-28"
|
|
85
|
+
const USER_AGENT = "headlesscode-push-pr"
|
|
86
|
+
|
|
87
|
+
function ghHeaders(token: string): Record<string, string> {
|
|
88
|
+
return {
|
|
89
|
+
Accept: "application/vnd.github+json",
|
|
90
|
+
"X-GitHub-Api-Version": GH_API_VERSION,
|
|
91
|
+
Authorization: `Bearer ${token}`,
|
|
92
|
+
"User-Agent": USER_AGENT,
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ErrorPayload {
|
|
98
|
+
message?: unknown
|
|
99
|
+
errors?: unknown
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function parseErrorPayload(response: Response): Promise<ErrorPayload> {
|
|
103
|
+
try {
|
|
104
|
+
return (await response.json()) as ErrorPayload
|
|
105
|
+
} catch {
|
|
106
|
+
return {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The message GitHub gives for "nothing to merge" (top-level or in errors[]). */
|
|
111
|
+
const NO_DIFF_PATTERNS = [/no commits between/i, /head and base branches are the same/i]
|
|
112
|
+
|
|
113
|
+
function isNoDiff(payload: ErrorPayload): boolean {
|
|
114
|
+
const message = payload.message
|
|
115
|
+
if (typeof message === "string" && NO_DIFF_PATTERNS.some((re) => re.test(message))) {
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(payload.errors)) {
|
|
119
|
+
for (const entry of payload.errors) {
|
|
120
|
+
if (entry === null || typeof entry !== "object") {
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
const entryMessage = (entry as { message?: unknown }).message
|
|
124
|
+
if (typeof entryMessage === "string" && NO_DIFF_PATTERNS.some((re) => re.test(entryMessage))) {
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatErrorMessage(payload: ErrorPayload, fallback: string): string {
|
|
133
|
+
if (typeof payload.message === "string" && payload.message !== "") {
|
|
134
|
+
return payload.message
|
|
135
|
+
}
|
|
136
|
+
return fallback
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The repo API's `default_branch` for <owner>/<repo> (never hardcoded). */
|
|
140
|
+
export async function getRepoDefaultBranch(options: {
|
|
141
|
+
owner: string
|
|
142
|
+
repo: string
|
|
143
|
+
getToken: TokenSupplier
|
|
144
|
+
baseUrl?: string
|
|
145
|
+
fetchImpl?: typeof fetch
|
|
146
|
+
}): Promise<string> {
|
|
147
|
+
const { owner, repo, getToken, baseUrl, fetchImpl = fetch } = options
|
|
148
|
+
const token = await getToken()
|
|
149
|
+
|
|
150
|
+
const apiBase = (baseUrl ?? DEFAULT_GH_BASE_URL).replace(/\/+$/, "")
|
|
151
|
+
const url = `${apiBase}/repos/${owner}/${repo}`
|
|
152
|
+
|
|
153
|
+
let response: Response
|
|
154
|
+
try {
|
|
155
|
+
response = await fetchImpl(url, {
|
|
156
|
+
method: "GET",
|
|
157
|
+
headers: ghHeaders(token),
|
|
158
|
+
})
|
|
159
|
+
} catch (err) {
|
|
160
|
+
throw new OpenPrError(
|
|
161
|
+
0,
|
|
162
|
+
`GitHub API network error (GET ${url}): ${err instanceof Error ? err.message : String(err)}`,
|
|
163
|
+
url,
|
|
164
|
+
{ cause: err },
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const payload = await parseErrorPayload(response)
|
|
170
|
+
throw new OpenPrError(response.status, formatErrorMessage(payload, `GitHub API HTTP ${response.status} for GET ${url}`), url)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const body = (await response.json()) as { default_branch?: unknown }
|
|
174
|
+
if (typeof body.default_branch !== "string" || body.default_branch === "") {
|
|
175
|
+
throw new OpenPrError(response.status, `repo API for ${owner}/${repo} returned no default_branch`, url)
|
|
176
|
+
}
|
|
177
|
+
return body.default_branch
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Open a PR from `head` into `base` (resolved to the repo's real default
|
|
182
|
+
* branch when omitted) on <owner>/<repo>. Returns the PR number + human-facing
|
|
183
|
+
* URL. A "nothing to merge" API response surfaces as NoDiffError.
|
|
184
|
+
*/
|
|
185
|
+
export async function openPullRequest(options: OpenPrOptions): Promise<OpenPrResult> {
|
|
186
|
+
const { owner, repo, head, title, body, getToken, baseUrl, fetchImpl = fetch } = options
|
|
187
|
+
if (!owner || !repo) {
|
|
188
|
+
throw new OpenPrError(0, "openPullRequest: owner and repo are required", "")
|
|
189
|
+
}
|
|
190
|
+
if (!head) {
|
|
191
|
+
throw new OpenPrError(0, "openPullRequest: head is required", "")
|
|
192
|
+
}
|
|
193
|
+
if (!title) {
|
|
194
|
+
throw new OpenPrError(0, "openPullRequest: title is required", "")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const token = await getToken()
|
|
198
|
+
if (!token) {
|
|
199
|
+
throw new OpenPrError(0, "openPullRequest: no installation token provided", "")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Resolve the real target branch fresh unless the caller overrode it.
|
|
203
|
+
const base = options.base ?? (await getRepoDefaultBranch({ owner, repo, getToken: async () => token, baseUrl, fetchImpl }))
|
|
204
|
+
|
|
205
|
+
const apiBase = (baseUrl ?? DEFAULT_GH_BASE_URL).replace(/\/+$/, "")
|
|
206
|
+
const url = `${apiBase}/repos/${owner}/${repo}/pulls`
|
|
207
|
+
|
|
208
|
+
let response: Response
|
|
209
|
+
try {
|
|
210
|
+
response = await fetchImpl(url, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: ghHeaders(token),
|
|
213
|
+
body: JSON.stringify({ title, head, base, body }),
|
|
214
|
+
})
|
|
215
|
+
} catch (err) {
|
|
216
|
+
throw new OpenPrError(
|
|
217
|
+
0,
|
|
218
|
+
`GitHub API network error (POST ${url}): ${err instanceof Error ? err.message : String(err)}`,
|
|
219
|
+
url,
|
|
220
|
+
{ cause: err },
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
const payload = await parseErrorPayload(response)
|
|
226
|
+
const message = formatErrorMessage(payload, `GitHub API HTTP ${response.status} for POST ${url}`)
|
|
227
|
+
if (isNoDiff(payload)) {
|
|
228
|
+
// Nothing to merge is a real, permanent API rejection (422) — a
|
|
229
|
+
// retry would fail identically. Surface it clearly, never retry.
|
|
230
|
+
throw new NoDiffError(response.status, `nothing to merge: ${message}`, url)
|
|
231
|
+
}
|
|
232
|
+
throw new OpenPrError(response.status, message, url)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const pr = (await response.json()) as { number?: unknown; html_url?: unknown }
|
|
236
|
+
if (typeof pr.number !== "number" || typeof pr.html_url !== "string") {
|
|
237
|
+
throw new OpenPrError(response.status, `unexpected PR response shape from POST ${url}`, url)
|
|
238
|
+
}
|
|
239
|
+
return { number: pr.number, url: pr.html_url }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface PullRequestStatus {
|
|
243
|
+
number: number
|
|
244
|
+
/** "open" | "closed" — the PR's lifecycle state, NOT its merge status. */
|
|
245
|
+
state: string
|
|
246
|
+
/** GitHub's server-computed merge boolean — the authoritative answer. */
|
|
247
|
+
merged: boolean
|
|
248
|
+
mergedAt?: string
|
|
249
|
+
htmlUrl?: string
|
|
250
|
+
title?: string
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read a PR's status back from `GET /repos/{owner}/{repo}/pulls/{number}`.
|
|
255
|
+
* The `.merged` field is computed server-side by GitHub and is the ONLY
|
|
256
|
+
* correct "is this PR merged" answer for squash/rebase merges (which create
|
|
257
|
+
* a new commit hash unrelated to anything on the source branch). Non-2xx
|
|
258
|
+
* API responses throw OpenPrError exactly like openPullRequest; a malformed
|
|
259
|
+
* 2xx body (missing `merged`/`state`) also throws rather than guessing.
|
|
260
|
+
*/
|
|
261
|
+
export async function getPullRequestStatus(options: {
|
|
262
|
+
owner: string
|
|
263
|
+
repo: string
|
|
264
|
+
prNumber: number
|
|
265
|
+
getToken: TokenSupplier
|
|
266
|
+
baseUrl?: string
|
|
267
|
+
fetchImpl?: typeof fetch
|
|
268
|
+
}): Promise<PullRequestStatus> {
|
|
269
|
+
const { owner, repo, prNumber, getToken, baseUrl, fetchImpl = fetch } = options
|
|
270
|
+
if (!owner || !repo || !Number.isInteger(prNumber) || prNumber <= 0) {
|
|
271
|
+
throw new OpenPrError(0, "getPullRequestStatus: owner, repo and a positive prNumber are required", "")
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const token = await getToken()
|
|
275
|
+
if (!token) {
|
|
276
|
+
throw new OpenPrError(0, "getPullRequestStatus: no installation token provided", "")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const apiBase = (baseUrl ?? DEFAULT_GH_BASE_URL).replace(/\/+$/, "")
|
|
280
|
+
const url = `${apiBase}/repos/${owner}/${repo}/pulls/${prNumber}`
|
|
281
|
+
|
|
282
|
+
let response: Response
|
|
283
|
+
try {
|
|
284
|
+
response = await fetchImpl(url, {
|
|
285
|
+
method: "GET",
|
|
286
|
+
headers: ghHeaders(token),
|
|
287
|
+
})
|
|
288
|
+
} catch (err) {
|
|
289
|
+
throw new OpenPrError(
|
|
290
|
+
0,
|
|
291
|
+
`GitHub API network error (GET ${url}): ${err instanceof Error ? err.message : String(err)}`,
|
|
292
|
+
url,
|
|
293
|
+
{ cause: err },
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
const payload = await parseErrorPayload(response)
|
|
299
|
+
throw new OpenPrError(response.status, formatErrorMessage(payload, `GitHub API HTTP ${response.status} for GET ${url}`), url)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const body = (await response.json()) as {
|
|
303
|
+
number?: unknown
|
|
304
|
+
state?: unknown
|
|
305
|
+
merged?: unknown
|
|
306
|
+
merged_at?: unknown
|
|
307
|
+
html_url?: unknown
|
|
308
|
+
title?: unknown
|
|
309
|
+
}
|
|
310
|
+
if (typeof body.number !== "number" || typeof body.state !== "string" || typeof body.merged !== "boolean") {
|
|
311
|
+
throw new OpenPrError(response.status, `unexpected PR status shape from GET ${url}`, url)
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
number: body.number,
|
|
315
|
+
state: body.state,
|
|
316
|
+
merged: body.merged,
|
|
317
|
+
...(typeof body.merged_at === "string" ? { mergedAt: body.merged_at } : {}),
|
|
318
|
+
...(typeof body.html_url === "string" ? { htmlUrl: body.html_url } : {}),
|
|
319
|
+
...(typeof body.title === "string" ? { title: body.title } : {}),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo provisioning: installation + repo → a real local clone.
|
|
3
|
+
*
|
|
4
|
+
* `provisionRepo()` gets a fresh installation access token (via
|
|
5
|
+
* src/github/app-auth.ts), constructs GitHub's documented authenticated clone
|
|
6
|
+
* URL for App installation tokens
|
|
7
|
+
* (https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation:
|
|
8
|
+
* `git clone https://x-access-token:TOKEN@github.com/owner/repo.git`), clones
|
|
9
|
+
* into `targetDir`, then REWRITES the remote to a plain token-free
|
|
10
|
+
* `https://github.com/owner/repo.git` so the token never lingers in
|
|
11
|
+
* `.git/config`. The resulting path is exactly what the rest of headlesscode
|
|
12
|
+
* expects as a `--workspace` value — no downstream changes needed.
|
|
13
|
+
*
|
|
14
|
+
* The token never touches disk beyond the transient clone URL (and even that
|
|
15
|
+
* is scrubbed immediately after): it is never written to the cloned repo's
|
|
16
|
+
* config, never logged, never persisted.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFile } from "node:child_process"
|
|
20
|
+
import * as fs from "node:fs"
|
|
21
|
+
import * as path from "node:path"
|
|
22
|
+
import { promisify } from "node:util"
|
|
23
|
+
|
|
24
|
+
const execFileP = promisify(execFile)
|
|
25
|
+
|
|
26
|
+
export interface ProvisionOptions {
|
|
27
|
+
installationId: number | string
|
|
28
|
+
owner: string
|
|
29
|
+
repo: string
|
|
30
|
+
/** Local directory to clone into. Created if missing; must be empty if it exists. */
|
|
31
|
+
targetDir: string
|
|
32
|
+
/** The function that supplies a fresh installation access token. */
|
|
33
|
+
getToken: () => Promise<string>
|
|
34
|
+
/** Optional explicit git binary (tests). Defaults to "git" on PATH. */
|
|
35
|
+
gitBin?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ProvisionError extends Error {
|
|
39
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
40
|
+
super(message, options)
|
|
41
|
+
this.name = "ProvisionError"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isDirEmpty(dir: string): boolean {
|
|
46
|
+
return fs.readdirSync(dir).length === 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Clone <owner>/<repo> into <targetDir> using a fresh installation token, then
|
|
51
|
+
* strip the token out of the clone's remote URL. Returns the absolute local
|
|
52
|
+
* path, ready for use as a `--workspace` value.
|
|
53
|
+
*/
|
|
54
|
+
export async function provisionRepo(options: ProvisionOptions): Promise<string> {
|
|
55
|
+
const { installationId, owner, repo, targetDir, getToken, gitBin = "git" } = options
|
|
56
|
+
if (!owner || !repo) {
|
|
57
|
+
throw new ProvisionError("provisionRepo: owner and repo are required")
|
|
58
|
+
}
|
|
59
|
+
if (!targetDir) {
|
|
60
|
+
throw new ProvisionError("provisionRepo: targetDir is required")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const target = path.resolve(targetDir)
|
|
64
|
+
|
|
65
|
+
// Refuse to silently overwrite: the target must be missing, or an existing
|
|
66
|
+
// EMPTY directory (a stray empty dir is fine to clone into).
|
|
67
|
+
if (fs.existsSync(target)) {
|
|
68
|
+
if (!fs.statSync(target).isDirectory()) {
|
|
69
|
+
throw new ProvisionError(`provisionRepo: target exists and is not a directory: ${target}`)
|
|
70
|
+
}
|
|
71
|
+
if (!isDirEmpty(target)) {
|
|
72
|
+
throw new ProvisionError(`provisionRepo: target directory is not empty: ${target}`)
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
fs.mkdirSync(target, { recursive: true })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const token = await getToken()
|
|
79
|
+
if (!token) {
|
|
80
|
+
throw new ProvisionError("provisionRepo: no installation token provided")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// GitHub's documented pattern for App installation tokens:
|
|
84
|
+
// git clone https://x-access-token:<token>@github.com/<owner>/<repo>.git
|
|
85
|
+
const authenticatedUrl = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`
|
|
86
|
+
const cleanUrl = `https://github.com/${owner}/${repo}.git`
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await execFileP(gitBin, ["clone", "--quiet", authenticatedUrl, target], { timeout: 120_000 })
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new ProvisionError(
|
|
92
|
+
`provisionRepo: git clone failed for ${owner}/${repo}: ${err instanceof Error ? err.message : String(err)}`,
|
|
93
|
+
{ cause: err },
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Security: rewrite the remote to a plain URL immediately, so the token
|
|
98
|
+
// does not linger in <target>/.git/config for the life of the clone.
|
|
99
|
+
try {
|
|
100
|
+
await execFileP(gitBin, ["-C", target, "remote", "set-url", "origin", cleanUrl], { timeout: 30_000 })
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw new ProvisionError(
|
|
103
|
+
`provisionRepo: clone succeeded but failed to strip the token from the remote URL: ${
|
|
104
|
+
err instanceof Error ? err.message : String(err)
|
|
105
|
+
}`,
|
|
106
|
+
{ cause: err },
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return target
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read the clone's origin URL (exported so the security test can assert on
|
|
114
|
+
* the ACTUAL .git/config contents, not on our own assumptions). */
|
|
115
|
+
export async function readOriginUrl(repoDir: string, gitBin = "git"): Promise<string> {
|
|
116
|
+
const { stdout } = await execFileP(gitBin, ["-C", repoDir, "remote", "get-url", "origin"])
|
|
117
|
+
return stdout.trim()
|
|
118
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo push-back: push a local branch to GitHub using the installation token.
|
|
3
|
+
*
|
|
4
|
+
* `pushBranch()` is the write half of repo provisioning: it gets a fresh
|
|
5
|
+
* installation access token (same `getToken` shape as `provisionRepo` in
|
|
6
|
+
* src/github/provision.ts), pushes the local HEAD to an explicitly NAMED
|
|
7
|
+
* branch on <owner>/<repo>, and strips the token out of the repo's origin URL
|
|
8
|
+
* before returning.
|
|
9
|
+
*
|
|
10
|
+
* Token discipline matches provision.ts exactly: the token only ever appears
|
|
11
|
+
* inside a transient `https://x-access-token:<token>@github.com/...` URL. The
|
|
12
|
+
* push is done via `git remote set-url origin <auth-url>` → `git push origin
|
|
13
|
+
* HEAD:<branch>` → `git remote set-url origin <original>` in a `finally`, so
|
|
14
|
+
* the token never persists in `.git/config` past this call — even when the
|
|
15
|
+
* push itself fails. The ORIGINAL origin URL is captured first and restored
|
|
16
|
+
* verbatim (for a provisioned repo that is the token-free
|
|
17
|
+
* `https://github.com/<owner>/<repo>.git`, but whatever it was, it ends up
|
|
18
|
+
* exactly as it started).
|
|
19
|
+
*
|
|
20
|
+
* Never pushes to a repo's default branch: the refspec is always the explicit
|
|
21
|
+
* `HEAD:<branchName>` — there is no code path that pushes to an implicit
|
|
22
|
+
* upstream/default branch. The CLI layer additionally refuses
|
|
23
|
+
* `--branch == --base` (see src/github/cli.ts).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFile } from "node:child_process"
|
|
27
|
+
import { promisify } from "node:util"
|
|
28
|
+
|
|
29
|
+
const execFileP = promisify(execFile)
|
|
30
|
+
|
|
31
|
+
export interface PushOptions {
|
|
32
|
+
installationId: number | string
|
|
33
|
+
owner: string
|
|
34
|
+
repo: string
|
|
35
|
+
/** Local repo path to push from (already has the work committed). */
|
|
36
|
+
localDir: string
|
|
37
|
+
/** Branch name to push to on the remote (created if it doesn't exist). */
|
|
38
|
+
branchName: string
|
|
39
|
+
/** The function that supplies a fresh installation access token. */
|
|
40
|
+
getToken: () => Promise<string>
|
|
41
|
+
/** Optional explicit git binary (tests). Defaults to "git" on PATH. */
|
|
42
|
+
gitBin?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class PushError extends Error {
|
|
46
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
47
|
+
super(message, options)
|
|
48
|
+
this.name = "PushError"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Push the current HEAD of <localDir> to <branchName> on <owner>/<repo>,
|
|
54
|
+
* using a fresh installation token, then restore the origin URL to its
|
|
55
|
+
* original token-free form. The token never persists past this call.
|
|
56
|
+
*/
|
|
57
|
+
export async function pushBranch(options: PushOptions): Promise<void> {
|
|
58
|
+
const { owner, repo, localDir, branchName, getToken, gitBin = "git" } = options
|
|
59
|
+
if (!owner || !repo) {
|
|
60
|
+
throw new PushError("pushBranch: owner and repo are required")
|
|
61
|
+
}
|
|
62
|
+
if (!localDir) {
|
|
63
|
+
throw new PushError("pushBranch: localDir is required")
|
|
64
|
+
}
|
|
65
|
+
if (!branchName) {
|
|
66
|
+
throw new PushError("pushBranch: branchName is required")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const token = await getToken()
|
|
70
|
+
if (!token) {
|
|
71
|
+
throw new PushError("pushBranch: no installation token provided")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// GitHub's documented pattern for App installation tokens (same as
|
|
75
|
+
// provision.ts): git push https://x-access-token:<token>@github.com/<owner>/<repo>.git
|
|
76
|
+
const authenticatedUrl = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`
|
|
77
|
+
|
|
78
|
+
// Capture the CURRENT origin URL BEFORE touching anything. Whatever it is
|
|
79
|
+
// (a provisioned repo's token-free clean URL, or anything else), it gets
|
|
80
|
+
// restored verbatim after the push — so this call is a no-op on the
|
|
81
|
+
// remote config except for the transient authenticated window inside it.
|
|
82
|
+
let originalUrl: string
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await execFileP(gitBin, ["-C", localDir, "remote", "get-url", "origin"], { timeout: 30_000 })
|
|
85
|
+
originalUrl = stdout.trim()
|
|
86
|
+
} catch (err) {
|
|
87
|
+
throw new PushError(
|
|
88
|
+
`pushBranch: cannot read the 'origin' remote URL in ${localDir}: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
|
+
{ cause: err },
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await execFileP(gitBin, ["-C", localDir, "remote", "set-url", "origin", authenticatedUrl], { timeout: 30_000 })
|
|
95
|
+
// Always an EXPLICIT named-branch refspec: `git push origin HEAD:<name>`
|
|
96
|
+
// creates/updates exactly <name> on the remote and never touches the
|
|
97
|
+
// repo's default branch (no bare `git push`, no upstream inference).
|
|
98
|
+
await execFileP(gitBin, ["-C", localDir, "push", "origin", `HEAD:${branchName}`], { timeout: 120_000 })
|
|
99
|
+
} catch (err) {
|
|
100
|
+
throw new PushError(
|
|
101
|
+
`pushBranch: git push ${owner}/${repo} failed for branch '${branchName}': ${
|
|
102
|
+
err instanceof Error ? err.message : String(err)
|
|
103
|
+
}`,
|
|
104
|
+
{ cause: err },
|
|
105
|
+
)
|
|
106
|
+
} finally {
|
|
107
|
+
// Security: strip the token out of .git/config IMMEDIATELY — including
|
|
108
|
+
// on failure. If the restore itself fails, that is the one error we
|
|
109
|
+
// cannot paper over (the token may be on disk), so it REPLACES any
|
|
110
|
+
// push error.
|
|
111
|
+
try {
|
|
112
|
+
await execFileP(gitBin, ["-C", localDir, "remote", "set-url", "origin", originalUrl], { timeout: 30_000 })
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new PushError(
|
|
115
|
+
`pushBranch: failed to strip the token from the origin URL in ${localDir}/.git/config: ${
|
|
116
|
+
err instanceof Error ? err.message : String(err)
|
|
117
|
+
}`,
|
|
118
|
+
{ cause: err },
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace codebase-index detection helpers.
|
|
3
|
+
*
|
|
4
|
+
* The codebase-search index now lives in the CENTRAL per-project data store
|
|
5
|
+
* (see src/project-store.ts): `<dataRoot>/projects/<project-key>/codesearch/
|
|
6
|
+
* index.jsonl`, keyed by the resolved git-common-dir parent so every worktree
|
|
7
|
+
* of a repo shares the SAME index — the property that made the old
|
|
8
|
+
* spawn-script index-seeding step unnecessary. These helpers answer "does a
|
|
9
|
+
* usable index exist here?" in one place, used by:
|
|
10
|
+
* - HeadlessSession (src/engine/loop.ts) for the read-only-nudge guardrail,
|
|
11
|
+
* - the spawn-parallel-worktrees scripts (which decide whether a fresh
|
|
12
|
+
* worktree already has an index — now a central-store lookup, no seeding),
|
|
13
|
+
* - the spawn-parallel-worktrees smoke test.
|
|
14
|
+
*
|
|
15
|
+
* A "usable" index is one that exists AND is non-empty (an empty file is a
|
|
16
|
+
* crashed/partial build — treat it as absent so the spawner rebuilds it).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fsp from "node:fs/promises"
|
|
20
|
+
import * as path from "node:path"
|
|
21
|
+
|
|
22
|
+
import { resolveProjectDataDir } from "./project-store.js"
|
|
23
|
+
|
|
24
|
+
/** Absolute path of the codebase-search index for a workspace root. */
|
|
25
|
+
export function indexFilePathFor(workspaceRoot: string): string {
|
|
26
|
+
return path.join(resolveProjectDataDir(workspaceRoot), "codesearch", "index.jsonl")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when the workspace already has a non-empty codebase-search index.
|
|
31
|
+
* Also counts a legacy workspace-relative `.headlesscode/codesearch/index.jsonl`
|
|
32
|
+
* (pre-migration grace — the same rule the spawn scripts rely on so a fresh
|
|
33
|
+
* worktree of an already-indexed repo never rebuilds unnecessarily).
|
|
34
|
+
*/
|
|
35
|
+
export async function hasCodebaseIndex(workspaceRoot: string): Promise<boolean> {
|
|
36
|
+
for (const file of [
|
|
37
|
+
indexFilePathFor(workspaceRoot),
|
|
38
|
+
path.join(workspaceRoot, ".headlesscode", "codesearch", "index.jsonl"),
|
|
39
|
+
]) {
|
|
40
|
+
try {
|
|
41
|
+
const st = await fsp.stat(file)
|
|
42
|
+
if (st.isFile() && st.size > 0) {
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// missing — try the next candidate
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* headlesscode package entry — runs the CLI when executed directly
|
|
3
|
+
* (`tsx src/index.ts`, `npm run dev`) and re-exports the public API for
|
|
4
|
+
* programmatic use.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { main } from "./cli.js"
|
|
8
|
+
|
|
9
|
+
// Run the CLI when this file is executed directly (e.g. `tsx src/index.ts`).
|
|
10
|
+
main()
|
|
11
|
+
.then((code) => {
|
|
12
|
+
process.exitCode = code
|
|
13
|
+
})
|
|
14
|
+
.catch((err) => {
|
|
15
|
+
process.stderr.write(`headlesscode: unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
|
16
|
+
process.exitCode = 2
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export * from "./cli.js"
|
|
20
|
+
export * from "./memory/index.js"
|
|
21
|
+
export { OpenRouterClient, OpenRouterError, DEFAULT_MODEL } from "./llm/openrouter.js"
|
|
22
|
+
export { HeadlessSession, truncateHistory, DEFAULT_MAX_TOKENS } from "./engine/loop.js"
|
|
23
|
+
export {
|
|
24
|
+
maybeCondense,
|
|
25
|
+
condenseOldestTurns,
|
|
26
|
+
computeCondenseCount,
|
|
27
|
+
computeCondensePlan,
|
|
28
|
+
DEFAULT_CONDENSE_THRESHOLD_FRACTION,
|
|
29
|
+
DEFAULT_CONDENSE_EARLY_FIRE_FRACTION,
|
|
30
|
+
DEFAULT_CONDENSE_MAX_TOKENS,
|
|
31
|
+
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
32
|
+
} from "./engine/condense.js"
|
|
33
|
+
export {
|
|
34
|
+
runLocalExplorePhase,
|
|
35
|
+
buildLocalExploreHandoffMessage,
|
|
36
|
+
buildLocalExploreTools,
|
|
37
|
+
OllamaLocalChatClient,
|
|
38
|
+
LocalExploreError,
|
|
39
|
+
isLocalExploreEnabled,
|
|
40
|
+
LOCAL_EXPLORE_ENV,
|
|
41
|
+
DEFAULT_LOCAL_EXPLORE_MODEL,
|
|
42
|
+
DEFAULT_LOCAL_EXPLORE_MAX_ITERATIONS,
|
|
43
|
+
DEFAULT_LOCAL_EXPLORE_CONTEXT_TOKENS,
|
|
44
|
+
EXPLORE_SYSTEM_PROMPT,
|
|
45
|
+
} from "./engine/local-explore.js"
|
|
46
|
+
export { Logger } from "./engine/logger.js"
|
|
47
|
+
export { parseToolCalls, parseToolCall, bestEffortPartialJson } from "./engine/parser.js"
|
|
48
|
+
export {
|
|
49
|
+
createHeadlessExecutor,
|
|
50
|
+
ToolExecutor,
|
|
51
|
+
resolveWithinWorkspace,
|
|
52
|
+
PathTraversalError,
|
|
53
|
+
} from "./tools/executor.js"
|
|
54
|
+
export {
|
|
55
|
+
buildSystemPrompt,
|
|
56
|
+
loadCustomModes,
|
|
57
|
+
selectToolsForMode,
|
|
58
|
+
EXECUTABLE_TOOL_NAMES,
|
|
59
|
+
} from "./engine/prompt.js"
|
|
60
|
+
export {
|
|
61
|
+
detectStacks,
|
|
62
|
+
loadStackRules,
|
|
63
|
+
appendStackRulesSection,
|
|
64
|
+
STACK_NAMES,
|
|
65
|
+
STACK_RULES_HEADER,
|
|
66
|
+
getCentralStackRulesDir,
|
|
67
|
+
getCentralStackRulesFile,
|
|
68
|
+
getProjectStackRulesFile,
|
|
69
|
+
} from "./engine/stacks.js"
|
|
70
|
+
export type { StackName, LoadedStackRules } from "./engine/stacks.js"
|
|
71
|
+
export type {
|
|
72
|
+
ChatMessage,
|
|
73
|
+
ChatTool,
|
|
74
|
+
ChatToolCall,
|
|
75
|
+
LlmClient,
|
|
76
|
+
LlmRequest,
|
|
77
|
+
LlmResponse,
|
|
78
|
+
ParsedToolCall,
|
|
79
|
+
SessionResult,
|
|
80
|
+
ToolResult,
|
|
81
|
+
} from "./engine/types.js"
|