@proagentstore/cli 0.4.56 → 0.4.58

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.
@@ -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,7 +2,7 @@ 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";
5
+ import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoSync, repoTree, runRepoGit } from "./inspect.js";
6
6
  import { switchRepoBranch } from "./repo-write.js";
7
7
  import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
8
8
  import { asTurnAuthor } from "./turn-author.js";
@@ -54,7 +54,15 @@ 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
+ }
59
+ /**
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 });
58
66
  }
59
67
  /**
60
68
  * The ONE write the platform may make in a checkout by itself (#322) — put it back on a branch
@@ -117,6 +125,7 @@ export class CodingRuntime {
117
125
  env: input.env,
118
126
  statePath: defaultStatePath(this.reposBaseDir),
119
127
  resumeFrom: input.resumeFrom,
128
+ seed: input.seed,
120
129
  ghScope: input.ghScope,
121
130
  bin: input.bin,
122
131
  });
@@ -126,8 +135,12 @@ export class CodingRuntime {
126
135
  // clears its own key on that exit. Reporting after would say "started clean" about a launch
127
136
  // that did carry a conversation, and the transcript (which shows the crash) would disagree.
128
137
  const resumed = session.resumedConversation;
138
+ // Read alongside `resumed`, and BEFORE `start()` for the same reason: a bad `--resume` that
139
+ // kills the process on spawn clears the engine's key, and a seed answer read afterwards would
140
+ // describe a different launch from the one the caller asked about.
141
+ const seeded = session.seededConversation;
129
142
  session.start();
130
- return { ...this.snapshot(input.sessionId), resumed };
143
+ return { ...this.snapshot(input.sessionId), resumed, seeded };
131
144
  }
132
145
  /**
133
146
  * The pane the brain reasons over + the inferred run state.
@@ -153,6 +166,8 @@ export class CodingRuntime {
153
166
  // exactly the question asked about a session that just stopped.
154
167
  authResolved: session.authResolved,
155
168
  engineRuntime: session.engineRuntime,
169
+ engineMode: session.engineMode,
170
+ engineModeWarning: session.engineModeWarning,
156
171
  // Reported on EVERY capture, including one where the session is not alive: "how did the
157
172
  // last turn end" is exactly the question asked about a session that just stopped, and
158
173
  // the omitted-when-null shape keeps "not measured" distinguishable from a verdict.
@@ -247,6 +262,8 @@ export class CodingRuntime {
247
262
  takeover: this.takeovers.has(sessionId),
248
263
  authResolved: s.authResolved,
249
264
  engineRuntime: s.engineRuntime,
265
+ engineMode: s.engineMode,
266
+ engineModeWarning: s.engineModeWarning,
250
267
  ghGuard: s.ghGuard,
251
268
  }));
252
269
  }
@@ -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,6 +331,18 @@ async function route(runner, req, res) {
240
331
  return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
241
332
  }
242
333
  }
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
+ }
243
346
  // The ONE write surface (#322). A standing policy may put a checkout back on the branch it
244
347
  // declared; it may not commit, discard, or touch a remote. An older runner 404s this, which the
245
348
  // cloud reports as "asked, not confirmed" rather than as done.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.56",
3
+ "version": "0.4.58",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",