@proagentstore/cli 0.4.57 → 0.4.59
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/dist/browser-runner/coding/engine-acts.js +7 -7
- package/dist/browser-runner/coding/engine-adapter.js +186 -0
- package/dist/browser-runner/coding/engine-usage.js +27 -3
- package/dist/browser-runner/coding/github-browse.js +796 -0
- package/dist/browser-runner/coding/handlers.js +1 -1
- package/dist/browser-runner/coding/headless.js +86 -120
- package/dist/browser-runner/coding/inspect.js +188 -13
- package/dist/browser-runner/coding/repo-write.js +127 -3
- package/dist/browser-runner/coding/repo.js +44 -1
- package/dist/browser-runner/coding/runtime.js +29 -9
- package/dist/browser-runner/server.js +108 -3
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { InspectError } from "./inspect.js";
|
|
4
|
+
import { InspectError, networkGitEnv } from "./inspect.js";
|
|
5
5
|
/**
|
|
6
6
|
* May this string become a git token?
|
|
7
7
|
*
|
|
@@ -32,11 +32,18 @@ export function gitWriteArgv(cmd, opts = {}) {
|
|
|
32
32
|
// discard work.
|
|
33
33
|
return ["checkout", opts.branch.trim(), "--"];
|
|
34
34
|
}
|
|
35
|
+
case "fast-forward":
|
|
36
|
+
// No caller-supplied token at all: the branch is whatever HEAD is (checked against the
|
|
37
|
+
// declared one BEFORE this runs), the remote is the upstream git records for it. `--ff-only`
|
|
38
|
+
// is the whole contract — git aborts, touching nothing, when the histories have diverged.
|
|
39
|
+
// `--no-rebase` so a `pull.rebase=true` config on the owner's machine cannot turn the
|
|
40
|
+
// abort into a rebase of their local commits.
|
|
41
|
+
return ["pull", "--ff-only", "--no-rebase"];
|
|
35
42
|
default:
|
|
36
43
|
throw new InspectError(`unsupported git write command: ${cmd}`);
|
|
37
44
|
}
|
|
38
45
|
}
|
|
39
|
-
function git(workDir, argv) {
|
|
46
|
+
function git(workDir, argv, opts = {}) {
|
|
40
47
|
// stderr is PIPED rather than inherited: git narrates a checkout on stderr, and the runner's
|
|
41
48
|
// console is a user-facing log, not a place for `Switched to branch 'main'`. Piping it is also
|
|
42
49
|
// what makes `e.stderr` available, which is the only honest sentence to put on the card when git
|
|
@@ -44,11 +51,22 @@ function git(workDir, argv) {
|
|
|
44
51
|
return execFileSync("git", argv, {
|
|
45
52
|
cwd: workDir,
|
|
46
53
|
encoding: "utf-8",
|
|
47
|
-
timeout: 15_000,
|
|
54
|
+
timeout: opts.timeout ?? 15_000,
|
|
48
55
|
maxBuffer: 1024 * 1024,
|
|
49
56
|
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
env: opts.env,
|
|
50
58
|
}).toString();
|
|
51
59
|
}
|
|
60
|
+
/** git's diagnosis is the FIRST `fatal:`/`error:` line of stderr; the tail is boilerplate. */
|
|
61
|
+
function gitFailureLine(e) {
|
|
62
|
+
const err = e;
|
|
63
|
+
const lines = String(err.stderr || err.message || "")
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((l) => l.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
const line = lines.find((l) => /^(fatal|error):/i.test(l)) ?? lines[0] ?? "git failed";
|
|
68
|
+
return line.replace(/^(fatal|error):\s*/i, "").slice(0, 200);
|
|
69
|
+
}
|
|
52
70
|
/** The current branch, or the short SHA when detached, or null when even that fails. */
|
|
53
71
|
function currentBranch(workDir) {
|
|
54
72
|
try {
|
|
@@ -134,3 +152,109 @@ export function switchRepoBranch(workDir, branch) {
|
|
|
134
152
|
}
|
|
135
153
|
return { ok: after === to, changed: after === to, from, to, branch: after, dirty: dirtyAfter };
|
|
136
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Bring a CLEAN checkout up to its upstream, or refuse and change nothing (#802).
|
|
157
|
+
*
|
|
158
|
+
* Every precondition is checked HERE, at the hands, not only in the cloud — the same rule
|
|
159
|
+
* `switchRepoBranch` follows and for the same reason: the cloud's picture of the tree is a read
|
|
160
|
+
* taken moments earlier over a relay, and the write has to be safe against the tree as it IS.
|
|
161
|
+
*
|
|
162
|
+
* dirty refused. A fast-forward that touches a file with uncommitted edits is aborted by
|
|
163
|
+
* git anyway, but one that does not touch it silently succeeds and leaves the diff
|
|
164
|
+
* sitting on a base it was not written against. Refusing is the only answer that
|
|
165
|
+
* cannot change what somebody's uncommitted work means.
|
|
166
|
+
* off-branch refused when `branch` is given and HEAD is elsewhere. The declared branch is the
|
|
167
|
+
* one the cloud judged stale; pulling whatever the checkout happens to be on would
|
|
168
|
+
* be acting on a verdict about a different ref.
|
|
169
|
+
* detached refused — there is no branch for a pull to advance.
|
|
170
|
+
* no-upstream refused — `git pull` with no tracking information is a prompt, and a guess about
|
|
171
|
+
* which remote was meant is exactly the decision this module must never make.
|
|
172
|
+
* diverged NOT a precondition: `--ff-only` is the check, and git makes it atomically against
|
|
173
|
+
* the real history. It surfaces as `error`, with the tree untouched.
|
|
174
|
+
*
|
|
175
|
+
* Never creates a branch, never sets an upstream, never stashes.
|
|
176
|
+
*/
|
|
177
|
+
export function fastForwardRepo(workDir, opts = {}) {
|
|
178
|
+
const want = typeof opts.branch === "string" && opts.branch.trim() ? opts.branch.trim() : null;
|
|
179
|
+
if (want !== null && !isSwitchableBranchName(want))
|
|
180
|
+
throw new InspectError(`unusable branch name: ${String(opts.branch)}`);
|
|
181
|
+
const base = { ok: false, changed: false, branch: null, upstream: null, from: null, to: null, commits: null, dirty: false };
|
|
182
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
183
|
+
return { ...base, refused: "not-a-repo" };
|
|
184
|
+
let abbrev;
|
|
185
|
+
try {
|
|
186
|
+
abbrev = git(workDir, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return { ...base, refused: "unknown-head" };
|
|
190
|
+
}
|
|
191
|
+
if (!abbrev)
|
|
192
|
+
return { ...base, refused: "unknown-head" };
|
|
193
|
+
if (abbrev === "HEAD")
|
|
194
|
+
return { ...base, refused: "detached" };
|
|
195
|
+
const branch = abbrev;
|
|
196
|
+
let dirty;
|
|
197
|
+
try {
|
|
198
|
+
dirty = isDirty(workDir);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
return { ...base, branch, error: gitFailureLine(e) };
|
|
202
|
+
}
|
|
203
|
+
if (dirty)
|
|
204
|
+
return { ...base, branch, dirty: true, refused: "dirty" };
|
|
205
|
+
if (want !== null && branch !== want)
|
|
206
|
+
return { ...base, branch, refused: "off-branch" };
|
|
207
|
+
let upstream;
|
|
208
|
+
try {
|
|
209
|
+
upstream = git(workDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).trim();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { ...base, branch, refused: "no-upstream" };
|
|
213
|
+
}
|
|
214
|
+
if (!upstream)
|
|
215
|
+
return { ...base, branch, refused: "no-upstream" };
|
|
216
|
+
let from;
|
|
217
|
+
try {
|
|
218
|
+
from = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
from = null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
// The one network call in this file: the no-prompt environment `inspect.ts` uses for
|
|
225
|
+
// fetches, and a timeout above the runner's fetch cap, because a pull IS a fetch first.
|
|
226
|
+
git(workDir, gitWriteArgv("fast-forward"), { timeout: 30_000, env: networkGitEnv() });
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
// `--ff-only` refused, the network failed, or auth was needed and (correctly) not prompted
|
|
230
|
+
// for. In every case git left the tree as it was; the sentence says which.
|
|
231
|
+
return { ...base, branch, upstream, from, to: from, error: gitFailureLine(e) };
|
|
232
|
+
}
|
|
233
|
+
// CONFIRM, do not assume — read HEAD back and count what arrived from git, not from the
|
|
234
|
+
// exit code.
|
|
235
|
+
let to;
|
|
236
|
+
try {
|
|
237
|
+
to = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
to = null;
|
|
241
|
+
}
|
|
242
|
+
let commits = null;
|
|
243
|
+
if (from && to) {
|
|
244
|
+
try {
|
|
245
|
+
const n = Number.parseInt(git(workDir, ["rev-list", "--count", `${from}..${to}`]).trim(), 10);
|
|
246
|
+
commits = Number.isFinite(n) ? n : null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
commits = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
let dirtyAfter;
|
|
253
|
+
try {
|
|
254
|
+
dirtyAfter = isDirty(workDir);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
dirtyAfter = null;
|
|
258
|
+
}
|
|
259
|
+
return { ok: to !== null, changed: Boolean(from && to && from !== to), branch, upstream, from, to, commits, dirty: dirtyAfter };
|
|
260
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
/**
|
|
@@ -72,6 +72,49 @@ export function authenticatedCloneUrl(cloneUrl, token, username) {
|
|
|
72
72
|
const user = encodeURIComponent(username || "x-access-token");
|
|
73
73
|
return cloneUrl.replace(/^https:\/\//i, `https://${user}:${encodeURIComponent(token)}@`);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse a GitHub SSH welcome banner into an identity name.
|
|
77
|
+
*
|
|
78
|
+
* GitHub writes to stderr on a successful `ssh -T git@github.com`:
|
|
79
|
+
* `Hi <name>! You've successfully authenticated, but GitHub does not provide shell access.`
|
|
80
|
+
*
|
|
81
|
+
* For a deploy key the name is `<org>/<repo>`. For a user account it is the login.
|
|
82
|
+
* This function is PURE so it can be unit-tested without a network.
|
|
83
|
+
*/
|
|
84
|
+
export function parseSshIdentity(raw) {
|
|
85
|
+
// The banner arrives on stderr; strip ANSI color/style sequences (`ESC[...m`) that some SSH
|
|
86
|
+
// versions prepend. The ESC byte is expressed via String.fromCharCode rather than as a literal
|
|
87
|
+
// in a regex pattern, because Biome's noControlCharactersInRegex rule rejects control characters
|
|
88
|
+
// in regex literals (same rule that transcript-lines.ts and tmux.ts suppress with biome-ignore).
|
|
89
|
+
const ESC = String.fromCharCode(27);
|
|
90
|
+
const clean = raw.split(ESC).join("").replace(/\[[0-9;]*m/g, "").trim();
|
|
91
|
+
const m = clean.match(/^Hi\s+([^!]+)!/m);
|
|
92
|
+
return m ? m[1].trim() : null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Ask the machine what git identity SSH presents for `host`.
|
|
96
|
+
*
|
|
97
|
+
* Uses `BatchMode=yes` so it never prompts for a passphrase, and `ConnectTimeout=5` so a
|
|
98
|
+
* firewall that drops packets (rather than refusing) does not stall the diagnostics response.
|
|
99
|
+
* `StrictHostKeyChecking=accept-new` avoids an interactive prompt on first connection.
|
|
100
|
+
*
|
|
101
|
+
* Never throws — every failure is an identity of `null`, because this is a transparency probe
|
|
102
|
+
* and a network hiccup must not make the diagnostics endpoint useless.
|
|
103
|
+
*/
|
|
104
|
+
export function probeGitSshIdentity(host) {
|
|
105
|
+
// `ssh -T` exits non-zero (1) even on success — GitHub's welcome message deliberately closes
|
|
106
|
+
// the connection without a shell. `spawnSync` is used rather than `execFileSync` so a non-zero
|
|
107
|
+
// exit does not throw; the content of stderr is what matters, not the exit code.
|
|
108
|
+
const result = spawnSync("ssh", ["-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=5", `git@${host}`], {
|
|
109
|
+
encoding: "utf-8",
|
|
110
|
+
timeout: 10_000,
|
|
111
|
+
});
|
|
112
|
+
// stderr carries the welcome message; stdout is empty for a normal `ssh -T`.
|
|
113
|
+
const raw = String(result.stderr ?? "").slice(0, 500);
|
|
114
|
+
const identity = parseSshIdentity(raw);
|
|
115
|
+
const isDeployKey = identity === null ? null : identity.includes("/");
|
|
116
|
+
return { checked: true, host, identity, isDeployKey, raw };
|
|
117
|
+
}
|
|
75
118
|
/**
|
|
76
119
|
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
77
120
|
* — an existing checkout is left alone (no clobber). For private repos the cloud
|
|
@@ -2,8 +2,8 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { RunnerInputError } from "../errors.js";
|
|
4
4
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
5
|
-
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoTree, runRepoGit } from "./inspect.js";
|
|
6
|
-
import { switchRepoBranch } from "./repo-write.js";
|
|
5
|
+
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoSync, repoTree, runRepoGit } from "./inspect.js";
|
|
6
|
+
import { fastForwardRepo, switchRepoBranch } from "./repo-write.js";
|
|
7
7
|
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
8
8
|
import { asTurnAuthor } from "./turn-author.js";
|
|
9
9
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
@@ -54,17 +54,33 @@ export class CodingRuntime {
|
|
|
54
54
|
}
|
|
55
55
|
/** Run a whitelisted read-only git command in the session's repo. */
|
|
56
56
|
git(input) {
|
|
57
|
-
return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n });
|
|
57
|
+
return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n, ref: input.ref });
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* Where the checkout stands against its upstream — fetch (cached), then count ahead/behind
|
|
61
|
+
* (#785). Never pulls. The one endpoint here that reaches the NETWORK, which is why it carries
|
|
62
|
+
* its own cache and its own no-prompt environment (`inspect.ts`).
|
|
63
|
+
*/
|
|
64
|
+
sync(input) {
|
|
65
|
+
return repoSync(this.resolveWorkDir(input), { branch: input.branch, forceFetch: input.forceFetch });
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The writes the platform may make in a checkout by itself — put it back on a branch it
|
|
69
|
+
* declared (#322), or fast-forward it to its upstream (#802) — or refuse. See `repo-write.ts`:
|
|
70
|
+
* fixed argv, clean tree required, nothing in the `checkout .`/`reset`/`clean`/`stash` family
|
|
71
|
+
* exists to be reached.
|
|
63
72
|
*/
|
|
64
73
|
gitWrite(input) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
const workDir = this.resolveWorkDir(input);
|
|
75
|
+
if (input.cmd === "switch-branch") {
|
|
76
|
+
if (typeof input.branch !== "string")
|
|
77
|
+
throw new InspectError("switch-branch needs a branch");
|
|
78
|
+
return switchRepoBranch(workDir, input.branch);
|
|
79
|
+
}
|
|
80
|
+
// The second verb (#802): bring a clean checkout that is ON `branch` up to its upstream.
|
|
81
|
+
if (input.cmd === "fast-forward")
|
|
82
|
+
return fastForwardRepo(workDir, { branch: input.branch });
|
|
83
|
+
throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
|
|
68
84
|
}
|
|
69
85
|
/**
|
|
70
86
|
* Find something in the repo — by file CONTENT or by file NAME (#508).
|
|
@@ -158,6 +174,8 @@ export class CodingRuntime {
|
|
|
158
174
|
// exactly the question asked about a session that just stopped.
|
|
159
175
|
authResolved: session.authResolved,
|
|
160
176
|
engineRuntime: session.engineRuntime,
|
|
177
|
+
engineMode: session.engineMode,
|
|
178
|
+
engineModeWarning: session.engineModeWarning,
|
|
161
179
|
// Reported on EVERY capture, including one where the session is not alive: "how did the
|
|
162
180
|
// last turn end" is exactly the question asked about a session that just stopped, and
|
|
163
181
|
// the omitted-when-null shape keeps "not measured" distinguishable from a verdict.
|
|
@@ -252,6 +270,8 @@ export class CodingRuntime {
|
|
|
252
270
|
takeover: this.takeovers.has(sessionId),
|
|
253
271
|
authResolved: s.authResolved,
|
|
254
272
|
engineRuntime: s.engineRuntime,
|
|
273
|
+
engineMode: s.engineMode,
|
|
274
|
+
engineModeWarning: s.engineModeWarning,
|
|
255
275
|
ghGuard: s.ghGuard,
|
|
256
276
|
}));
|
|
257
277
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
import { URL } from "node:url";
|
|
3
3
|
import { LocalRunner, RunnerInputError } from "./runner.js";
|
|
4
|
+
import { probeGitSshIdentity } from "./coding/repo.js";
|
|
5
|
+
import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail, getGithubCredentialScope } from "./coding/github-browse.js";
|
|
4
6
|
export function createRunnerServer(runner) {
|
|
5
7
|
return createServer(async (req, res) => {
|
|
6
8
|
try {
|
|
@@ -176,6 +178,95 @@ async function route(runner, req, res) {
|
|
|
176
178
|
// use tmux, have their own /tmux/* endpoints and are unaffected.
|
|
177
179
|
return json(res, 200, { tracked: runner.coding.diagnostics() });
|
|
178
180
|
}
|
|
181
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/coding/git-identity") {
|
|
182
|
+
// Report the SSH identity this machine presents to github.com (#684). Called by
|
|
183
|
+
// `coding_diagnostics` to surface deploy-key vs user-account mismatches without a
|
|
184
|
+
// failed clone. Never throws on the runner side — a network hiccup returns identity:null.
|
|
185
|
+
const b = req.method === "POST" ? await readJson(req).catch(() => ({})) : {};
|
|
186
|
+
const host = String(b.host || "github.com");
|
|
187
|
+
return json(res, 200, probeGitSshIdentity(host));
|
|
188
|
+
}
|
|
189
|
+
// ── GitHub credential scope (#688) ────────────────────────────────────────
|
|
190
|
+
// Read-only: reports the authenticated gh login + org memberships. Surfaces
|
|
191
|
+
// which account and organisations the runner's credentials can reach, before
|
|
192
|
+
// any browse operation begins. Never writes, never mutates anything on GitHub.
|
|
193
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-credentials") {
|
|
194
|
+
return json(res, 200, getGithubCredentialScope());
|
|
195
|
+
}
|
|
196
|
+
// ── GitHub org + repo enumeration (#685) ──────────────────────────────────
|
|
197
|
+
// Read-only: lists orgs and repos reachable by the machine's `gh` credentials.
|
|
198
|
+
// Never writes, never mutates anything on GitHub.
|
|
199
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-orgs") {
|
|
200
|
+
return json(res, 200, listGithubOrgs());
|
|
201
|
+
}
|
|
202
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-repos") {
|
|
203
|
+
const b = req.method === "POST"
|
|
204
|
+
? await readJson(req).catch(() => ({}))
|
|
205
|
+
: {};
|
|
206
|
+
// Query params override body for GET callers (more REST-idiomatic).
|
|
207
|
+
const qOwner = url.searchParams.get("owner");
|
|
208
|
+
const qLimit = url.searchParams.get("limit");
|
|
209
|
+
const qSince = url.searchParams.get("since");
|
|
210
|
+
const qVis = url.searchParams.get("visibility");
|
|
211
|
+
const input = {
|
|
212
|
+
owner: qOwner ?? b.owner,
|
|
213
|
+
limit: qLimit ? Number(qLimit) : b.limit,
|
|
214
|
+
since: qSince ?? b.since,
|
|
215
|
+
visibility: (qVis === "public" || qVis === "private" || qVis === "all")
|
|
216
|
+
? qVis
|
|
217
|
+
: b.visibility,
|
|
218
|
+
};
|
|
219
|
+
return json(res, 200, listGithubRepos(input));
|
|
220
|
+
}
|
|
221
|
+
// ── GitHub repository search (#686) ───────────────────────────────────────
|
|
222
|
+
// Read-only: searches repos reachable by the machine's `gh` credentials via
|
|
223
|
+
// GitHub's own search API. One API call per query (no per-repo fan-out).
|
|
224
|
+
// Results are cached in-process for 5 minutes to guard the 30 req/min quota.
|
|
225
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-search") {
|
|
226
|
+
const b = req.method === "POST"
|
|
227
|
+
? await readJson(req).catch(() => ({}))
|
|
228
|
+
: {};
|
|
229
|
+
const qQuery = url.searchParams.get("query");
|
|
230
|
+
const qOwner = url.searchParams.get("owner");
|
|
231
|
+
const qLang = url.searchParams.get("language");
|
|
232
|
+
const qTopic = url.searchParams.get("topic");
|
|
233
|
+
const qPushedAfter = url.searchParams.get("pushedAfter");
|
|
234
|
+
const qOpenPrs = url.searchParams.get("openPrs");
|
|
235
|
+
const qLimit = url.searchParams.get("limit");
|
|
236
|
+
const qSort = url.searchParams.get("sort");
|
|
237
|
+
const bTyped = b;
|
|
238
|
+
const input = {
|
|
239
|
+
query: qQuery ?? bTyped.query,
|
|
240
|
+
owner: qOwner ?? bTyped.owner,
|
|
241
|
+
language: qLang ?? bTyped.language,
|
|
242
|
+
topic: qTopic ?? bTyped.topic,
|
|
243
|
+
pushedAfter: qPushedAfter ?? bTyped.pushedAfter,
|
|
244
|
+
openPrs: qOpenPrs !== null ? qOpenPrs === "true" : bTyped.openPrs,
|
|
245
|
+
limit: qLimit ? Number(qLimit) : bTyped.limit,
|
|
246
|
+
sort: (qSort === "stars" || qSort === "forks" || qSort === "updated")
|
|
247
|
+
? qSort
|
|
248
|
+
: bTyped.sort,
|
|
249
|
+
};
|
|
250
|
+
return json(res, 200, searchGithubRepos(input));
|
|
251
|
+
}
|
|
252
|
+
// ── GitHub repository detail (#687) ──────────────────────────────────────
|
|
253
|
+
// Read-only: fetches issues, PRs, and branches for a given owner/repo slug
|
|
254
|
+
// via `gh api`. Results are cached in-process for 2 minutes.
|
|
255
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-repo-detail") {
|
|
256
|
+
const b = req.method === "POST"
|
|
257
|
+
? await readJson(req).catch(() => ({}))
|
|
258
|
+
: {};
|
|
259
|
+
const bTyped = b;
|
|
260
|
+
const qRepo = url.searchParams.get("repo");
|
|
261
|
+
const qLimit = url.searchParams.get("limit");
|
|
262
|
+
const qState = url.searchParams.get("state");
|
|
263
|
+
const input = {
|
|
264
|
+
repo: qRepo ?? bTyped.repo ?? "",
|
|
265
|
+
limit: qLimit ? Number(qLimit) : bTyped.limit,
|
|
266
|
+
state: (qState === "all" || qState === "open") ? qState : bTyped.state,
|
|
267
|
+
};
|
|
268
|
+
return json(res, 200, getGithubRepoDetail(input));
|
|
269
|
+
}
|
|
179
270
|
if (req.method === "POST" && path === "/coding/browse") {
|
|
180
271
|
const { readdirSync, statSync } = await import("node:fs");
|
|
181
272
|
const { resolve } = await import("node:path");
|
|
@@ -240,9 +331,23 @@ async function route(runner, req, res) {
|
|
|
240
331
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
241
332
|
}
|
|
242
333
|
}
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
334
|
+
// Where the checkout stands against its upstream (#785): fetch (cached a minute), then count
|
|
335
|
+
// ahead/behind. Never pulls. A SEPARATE endpoint for the reason `/coding/search` is one: an
|
|
336
|
+
// older runner 404s it, which the cloud reads as "unverified" — never as "in sync".
|
|
337
|
+
if (req.method === "POST" && path === "/coding/sync") {
|
|
338
|
+
const b = await readJson(req);
|
|
339
|
+
try {
|
|
340
|
+
return json(res, 200, runner.coding.sync(b));
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// The ONE write surface (#322, #802). A standing policy may put a checkout back on the branch
|
|
347
|
+
// it declared, and a run may fast-forward a clean checkout to its upstream before it starts; it
|
|
348
|
+
// may not commit, discard, or push. An older runner 404s the endpoint (and a 0.4.58 runner
|
|
349
|
+
// 400s the `fast-forward` verb), which the cloud reports as "asked, not confirmed" rather than
|
|
350
|
+
// as done.
|
|
246
351
|
if (req.method === "POST" && path === "/coding/git-write") {
|
|
247
352
|
const b = await readJson(req);
|
|
248
353
|
try {
|