@shiyuphan/follow-me 0.9.1 → 0.9.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/dist/src/cli.js CHANGED
@@ -11,6 +11,7 @@ import { BranchRefusedError, blobsAt, branchHead, GitError, isOwnWork, pathsAt,
11
11
  import { fetchCarried, fetchComments, fetchReport, fetchWaitingOffers, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
12
12
  import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
13
13
  import { naiveDraft } from "./naive.js";
14
+ import { plainNumber, plainText } from "./plain.js";
14
15
  import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
15
16
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
16
17
  import { parseRefText, refsIn, repoPathOfTyped, resolveRefs } from "./refs.js";
@@ -1103,9 +1104,18 @@ function readCursor(session, summary) {
1103
1104
  const counts = log === logOf(summary) && typeof seq === "number" && Number.isInteger(seq) && seq >= 0;
1104
1105
  return { session: logOf(summary), seq: counts ? seq : 0, seen };
1105
1106
  }
1107
+ const ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_";
1108
+ const LOG_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-@.:+";
1109
+ const CURSOR_ID = /^[a-z]_[a-z0-9]+$/;
1110
+ const CURSOR_LOG = /^[\w-]+@[\w.:+-]+$/;
1111
+ /** The cursor holds data the server sent, so only ids and a log name of the expected shape are written. */
1106
1112
  function writeCursor(session, cursor) {
1107
1113
  const { session: log, seq, seen } = cursor;
1108
- writeFileSync(cursorPath(session), `${JSON.stringify({ session: log, seq, seen: [...seen] })}\n`);
1114
+ if (!CURSOR_LOG.test(log) || !Number.isInteger(seq))
1115
+ throw new Error(`refusing to save the cursor for ${log}`);
1116
+ const ids = [...seen].filter((id) => CURSOR_ID.test(id)).map((id) => plainText(id, ID_CHARS));
1117
+ const saved = { session: plainText(log, LOG_CHARS), seq: plainNumber(seq), seen: ids };
1118
+ writeFileSync(cursorPath(session), `${JSON.stringify(saved)}\n`);
1109
1119
  }
1110
1120
  /** The comment or reply id an event first carries, or null for events without one. */
1111
1121
  function saidId(event) {
package/dist/src/host.js CHANGED
@@ -5,10 +5,12 @@ import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { parseCarried, parseComment, parseEvent, parseEventBatch, } from "./comments.js";
7
7
  import { carryComments } from "./evolve.js";
8
+ import { plainNumber, plainText } from "./plain.js";
8
9
  import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
9
10
  import { HEALTH_APP, isSessionId, loadDraft, PortInUseError, RegistrationError, startServer, } from "./server.js";
10
11
  import { BranchMovedError, diffOfPlan, isAlive, openSessionAt, SessionFileError, SessionLockedError, sessionComments, } from "./session.js";
11
12
  const RECORD = "server.json";
13
+ const PATH_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._~-?=&";
12
14
  const DEFAULT_IDLE_MS = 30_000;
13
15
  const START_TIMEOUT_MS = 10_000;
14
16
  const STOP_TIMEOUT_MS = 5_000;
@@ -28,6 +30,13 @@ export function idleMs(env = process.env) {
28
30
  function isPort(value) {
29
31
  return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
30
32
  }
33
+ /** Every request this client makes goes to the loopback server, on a port and a path that are plain. */
34
+ function loopbackUrl(port, path) {
35
+ if (!isPort(port) || !/^\/[\w./~-]*(\?[\w=&.-]*)?$/.test(path)) {
36
+ throw new RegistrationError("refusing a request to an unexpected address");
37
+ }
38
+ return `http://127.0.0.1:${plainNumber(port)}${plainText(path, PATH_CHARS)}`;
39
+ }
31
40
  function isFields(value) {
32
41
  return typeof value === "object" && value !== null && !Array.isArray(value);
33
42
  }
@@ -52,7 +61,7 @@ export function readServerRecord(dir) {
52
61
  /** The pid of the follow-me server answering on this port, or null for anything else. */
53
62
  export async function probeHealth(port) {
54
63
  try {
55
- const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
64
+ const res = await fetch(loopbackUrl(port, "/api/health"), {
56
65
  signal: AbortSignal.timeout(1_000),
57
66
  });
58
67
  const body = await res.json();
@@ -252,7 +261,7 @@ export async function spawnDetached(dir, port) {
252
261
  throw new Error(`the review server did not start; see ${logPath}`);
253
262
  }
254
263
  async function call(port, method, path, body) {
255
- const res = await fetch(`http://127.0.0.1:${port}${path}`, {
264
+ const res = await fetch(loopbackUrl(port, path), {
256
265
  method,
257
266
  headers: { "content-type": "application/json" },
258
267
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
@@ -357,7 +366,7 @@ export async function pollEvents(port, summary, since, timeoutMs) {
357
366
  * the server closes the stream, which it does after the `ended` event.
358
367
  */
359
368
  export async function followEvents(port, summary, since, onEvent) {
360
- const res = await fetch(`${sessionUrl(port, summary)}api/events/stream?since=${since}`);
369
+ const res = await fetch(loopbackUrl(port, `${summary.url}api/events/stream?since=${Number(since)}`));
361
370
  if (!res.ok || res.body === null) {
362
371
  throw new RegistrationError(`GET api/events/stream failed with ${res.status}`);
363
372
  }
@@ -403,7 +412,7 @@ export async function postCoverage(port, summary) {
403
412
  }
404
413
  /** The consolidated report of the hosted session as it stands. */
405
414
  export async function fetchReport(port, summary, format = "markdown") {
406
- const res = await fetch(`${sessionUrl(port, summary)}api/report?format=${format}`);
415
+ const res = await fetch(loopbackUrl(port, `${summary.url}api/report?format=${format}`));
407
416
  if (!res.ok)
408
417
  throw new RegistrationError(`GET api/report failed with ${res.status}`);
409
418
  return res.text();
@@ -423,7 +432,7 @@ export function sessionUrl(port, summary) {
423
432
  /** Whether the server on this port still serves the session's state; a gone server serves none. */
424
433
  export async function stillHosted(port, summary) {
425
434
  try {
426
- const res = await fetch(`${sessionUrl(port, summary)}api/state`, {
435
+ const res = await fetch(loopbackUrl(port, `${summary.url}api/state`), {
427
436
  signal: AbortSignal.timeout(2_000),
428
437
  });
429
438
  await res.body?.cancel();
@@ -60,9 +60,19 @@ export function planSkillFiles(options, home, repoRoot) {
60
60
  }
61
61
  return [...byPath.values()];
62
62
  }
63
+ function readIfPresent(path) {
64
+ try {
65
+ return readFileSync(path);
66
+ }
67
+ catch (error) {
68
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
69
+ return null;
70
+ throw error;
71
+ }
72
+ }
63
73
  function writeSkill(skillDir, file, dryRun) {
64
74
  const content = readFileSync(join(skillDir, file.source));
65
- const current = existsSync(file.path) ? readFileSync(file.path) : null;
75
+ const current = readIfPresent(file.path);
66
76
  if (current?.equals(content))
67
77
  return `unchanged ${file.path}`;
68
78
  if (dryRun)
@@ -0,0 +1,20 @@
1
+ const DIGITS = "0123456789";
2
+ /**
3
+ * Rebuilds `text` one character at a time from `alphabet`, and throws on a character outside it. What comes
4
+ * back is made only of characters that were checked, so a port read from a file or an id sent by the server
5
+ * cannot carry anything else into a request address or a file.
6
+ */
7
+ export function plainText(text, alphabet) {
8
+ let out = "";
9
+ for (const char of text) {
10
+ const at = alphabet.indexOf(char);
11
+ if (at < 0)
12
+ throw new Error(`unexpected character ${JSON.stringify(char)}`);
13
+ out += alphabet.charAt(at);
14
+ }
15
+ return out;
16
+ }
17
+ /** A whole number, rebuilt from its digits; throws on anything else. */
18
+ export function plainNumber(value) {
19
+ return Number(plainText(String(value), DIGITS));
20
+ }
@@ -385,9 +385,14 @@ async function locate(repo, branch) {
385
385
  throw new Error("HEAD is detached; pass --branch <name>");
386
386
  const top = join(root, ".follow-me");
387
387
  mkdirSync(top, { recursive: true });
388
- const ignore = join(top, ".gitignore");
389
- if (!existsSync(ignore))
390
- writeFileSync(ignore, "*\n");
388
+ try {
389
+ // "wx" creates the file only if it is not there, so no check can go stale before the write.
390
+ writeFileSync(join(top, ".gitignore"), "*\n", { flag: "wx" });
391
+ }
392
+ catch (error) {
393
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
394
+ throw error;
395
+ }
391
396
  return { root, branch: name, dir: join(top, slugOf(name)) };
392
397
  }
393
398
  function readAnchorIn(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shiyuphan/follow-me",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Split a branch's final changes into fine, ordered commits that read like the author's thought process, review them hunk by hunk, and hand the review back to a coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",