@astrohacker/factory 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/ahfact.js +202 -22
  2. package/package.json +1 -1
package/dist/ahfact.js CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // cli/ahfact.ts
4
- import { execFileSync } from "node:child_process";
5
- import { chmod, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { chmod, mkdir, readdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
6
5
  import { homedir, hostname } from "node:os";
7
- import { join } from "node:path";
6
+ import { join as join2 } from "node:path";
8
7
  import { parseArgs } from "node:util";
9
8
 
10
9
  // node_modules/@orpc/shared/dist/index.mjs
@@ -1613,6 +1612,140 @@ function parseGitHubRemote(remote) {
1613
1612
  return `${owner}/${name}`;
1614
1613
  }
1615
1614
 
1615
+ // cli/github-read.ts
1616
+ import { execFile } from "node:child_process";
1617
+ var ISSUE_LIMIT = 300;
1618
+ var PR_LIMIT = 200;
1619
+ var ISSUE_FIELDS = "number,title,state,labels,url";
1620
+ var PR_FIELDS = "number,title,state,isDraft,headRefOid,headRefName,baseRefName,labels,closingIssuesReferences,author,url,mergeCommit";
1621
+ function mapIssue(i) {
1622
+ return { number: i.number, title: i.title, state: i.state === "OPEN" ? "open" : "closed", labels: i.labels.map((l) => l.name), url: i.url };
1623
+ }
1624
+ function mapPullRequest(p, repo) {
1625
+ const sameRepo = (url) => url === undefined || url.toLowerCase().startsWith(`https://github.com/${repo.toLowerCase()}/`);
1626
+ return {
1627
+ number: p.number,
1628
+ title: p.title,
1629
+ state: p.state === "MERGED" ? "merged" : p.state === "OPEN" ? "open" : "closed",
1630
+ draft: p.isDraft,
1631
+ headSha: p.headRefOid,
1632
+ headRef: p.headRefName,
1633
+ baseRef: p.baseRefName,
1634
+ labels: p.labels.map((l) => l.name),
1635
+ closingIssues: p.closingIssuesReferences.filter((r) => sameRepo(r.url)).map((r) => r.number),
1636
+ author: p.author?.login ?? "",
1637
+ url: p.url,
1638
+ mergeCommit: p.mergeCommit?.oid ?? null
1639
+ };
1640
+ }
1641
+ function gh(args) {
1642
+ return new Promise((resolve, reject) => {
1643
+ execFile("gh", args, { maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
1644
+ if (error)
1645
+ reject(new Error(`gh ${args.slice(0, 2).join(" ")} failed: ${(stderr || error.message).trim()}`));
1646
+ else
1647
+ resolve(stdout);
1648
+ });
1649
+ });
1650
+ }
1651
+ async function readGitHub(repo) {
1652
+ const [issues, prs] = await Promise.all([
1653
+ gh(["issue", "list", "--repo", repo, "--state", "all", "--limit", String(ISSUE_LIMIT), "--json", ISSUE_FIELDS]),
1654
+ gh(["pr", "list", "--repo", repo, "--state", "all", "--limit", String(PR_LIMIT), "--json", PR_FIELDS])
1655
+ ]);
1656
+ return {
1657
+ issues: JSON.parse(issues).map(mapIssue),
1658
+ pullRequests: JSON.parse(prs).map((p) => mapPullRequest(p, repo))
1659
+ };
1660
+ }
1661
+
1662
+ // cli/repo.ts
1663
+ import { execFileSync, spawn } from "node:child_process";
1664
+ import { readFile } from "node:fs/promises";
1665
+ import { join } from "node:path";
1666
+ var HOOKS = ["setup", "check", "deploy"];
1667
+
1668
+ class ConfigError extends Error {
1669
+ }
1670
+ function repoRoot(cwd = process.cwd()) {
1671
+ try {
1672
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1673
+ } catch {
1674
+ return null;
1675
+ }
1676
+ }
1677
+ function checkoutRepo(cwd = process.cwd()) {
1678
+ try {
1679
+ const url = execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1680
+ return parseGitHubRemote(url);
1681
+ } catch {
1682
+ return null;
1683
+ }
1684
+ }
1685
+ function parseRepoConfig(text, file = ".ahfact.json") {
1686
+ let raw;
1687
+ try {
1688
+ raw = JSON.parse(text);
1689
+ } catch (error) {
1690
+ throw new ConfigError(`${file} is not valid JSON: ${error.message}`);
1691
+ }
1692
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
1693
+ throw new ConfigError(`${file} must be a JSON object.`);
1694
+ const obj = raw;
1695
+ const config = { hooks: {} };
1696
+ if (obj["project"] !== undefined) {
1697
+ if (typeof obj["project"] !== "string")
1698
+ throw new ConfigError(`${file}: "project" must be a string.`);
1699
+ config.project = obj["project"];
1700
+ }
1701
+ const hooks = obj["hooks"] ?? {};
1702
+ if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
1703
+ throw new ConfigError(`${file}: "hooks" must be an object.`);
1704
+ for (const [name, command] of Object.entries(hooks)) {
1705
+ if (!HOOKS.includes(name)) {
1706
+ throw new ConfigError(`${file}: unknown hook "${name}". Known hooks: ${HOOKS.join(", ")}.`);
1707
+ }
1708
+ if (typeof command !== "string" || command.trim() === "")
1709
+ throw new ConfigError(`${file}: hook "${name}" must be a command string.`);
1710
+ config.hooks[name] = command;
1711
+ }
1712
+ return config;
1713
+ }
1714
+ async function loadRepoConfig(root) {
1715
+ const file = join(root, ".ahfact.json");
1716
+ let text;
1717
+ try {
1718
+ text = await readFile(file, "utf8");
1719
+ } catch {
1720
+ return { hooks: {} };
1721
+ }
1722
+ return parseRepoConfig(text, file);
1723
+ }
1724
+ var TAIL_LINES = 40;
1725
+ function runHook(root, hook, command, env = {}) {
1726
+ const started = Date.now();
1727
+ return new Promise((resolve) => {
1728
+ const child = spawn("sh", ["-c", command], { cwd: root, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
1729
+ let output = "";
1730
+ const collect = (chunk) => {
1731
+ process.stderr.write(chunk);
1732
+ output = (output + chunk.toString("utf8")).slice(-64000);
1733
+ };
1734
+ child.stdout.on("data", collect);
1735
+ child.stderr.on("data", collect);
1736
+ child.on("close", (code) => {
1737
+ const exitCode = code ?? 1;
1738
+ const tail = output.trimEnd().split(`
1739
+ `).slice(-TAIL_LINES).join(`
1740
+ `);
1741
+ resolve({ hook, command, ok: exitCode === 0, exitCode, durationMs: Date.now() - started, tail });
1742
+ });
1743
+ child.on("error", (error) => {
1744
+ resolve({ hook, command, ok: false, exitCode: 127, durationMs: Date.now() - started, tail: error.message });
1745
+ });
1746
+ });
1747
+ }
1748
+
1616
1749
  // cli/ahfact.ts
1617
1750
  var DEFAULT_SERVER = "https://ahfact.com";
1618
1751
  var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
@@ -1633,11 +1766,18 @@ Workers (one per project, harness and computer):
1633
1766
  ahfact worker start --harness H [--machine M]
1634
1767
  create or reuse this checkout's worker and save its token
1635
1768
  ahfact worker whoami the worker this checkout's commands act as
1636
- ahfact wait [--timeout SECONDS] lease the next job, waiting until one comes (or the timeout)
1769
+ ahfact sync [--force] read this project's GitHub issues and PRs with gh and report them;
1770
+ ahfact.com turns them into jobs (one worker at a time, every 60s)
1771
+ ahfact wait [--timeout SECONDS] [--no-sync]
1772
+ sync when due, then lease the next job; repeat until one comes
1637
1773
  ahfact next [--wait SECONDS] one attempt, waiting at most 55 seconds; prints null if none
1638
1774
  ahfact leases the worker's live leases, to resume after a restart
1639
1775
  ahfact heartbeat say "still here"
1640
1776
 
1777
+ This checkout (hooks from .ahfact.json):
1778
+ ahfact setup run the setup hook, such as installing dependencies
1779
+ ahfact check run the check hook; prints {ok, exitCode, durationMs, tail}
1780
+
1641
1781
  Jobs:
1642
1782
  ahfact job create --kind K --subject S --title T [--harness H] [--machine M] [--exclude AGENT]...
1643
1783
 
@@ -1658,26 +1798,26 @@ Credentials live in ~/.config/ahfact/, mode 0600.`;
1658
1798
  class UsageError extends Error {
1659
1799
  }
1660
1800
  function configDir() {
1661
- return join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "ahfact");
1801
+ return join2(process.env["XDG_CONFIG_HOME"] ?? join2(homedir(), ".config"), "ahfact");
1662
1802
  }
1663
1803
  function hostOf(server) {
1664
1804
  return new URL(server).host.replace(/[^A-Za-z0-9.-]/g, "_");
1665
1805
  }
1666
1806
  async function writePrivate(file, value) {
1667
- await mkdir(join(file, ".."), { recursive: true, mode: 448 });
1807
+ await mkdir(join2(file, ".."), { recursive: true, mode: 448 });
1668
1808
  await writeFile(file, JSON.stringify(value, null, 2) + `
1669
1809
  `, { mode: 384 });
1670
1810
  await chmod(file, 384);
1671
1811
  }
1672
1812
  async function readJson(file) {
1673
1813
  try {
1674
- return JSON.parse(await readFile(file, "utf8"));
1814
+ return JSON.parse(await readFile2(file, "utf8"));
1675
1815
  } catch {
1676
1816
  return null;
1677
1817
  }
1678
1818
  }
1679
- var personFile = (server) => join(configDir(), "people", `${hostOf(server)}.json`);
1680
- var workerFile = (worker) => join(configDir(), "workers", `${worker}.json`);
1819
+ var personFile = (server) => join2(configDir(), "people", `${hostOf(server)}.json`);
1820
+ var workerFile = (worker) => join2(configDir(), "workers", `${worker}.json`);
1681
1821
  async function loadPerson(server) {
1682
1822
  const cred = await readJson(personFile(server));
1683
1823
  if (cred === null || cred.server !== server)
@@ -1687,24 +1827,16 @@ async function loadPerson(server) {
1687
1827
  async function allWorkers(server) {
1688
1828
  let files = [];
1689
1829
  try {
1690
- files = (await readdir(join(configDir(), "workers"))).filter((f) => f.endsWith(".json"));
1830
+ files = (await readdir(join2(configDir(), "workers"))).filter((f) => f.endsWith(".json"));
1691
1831
  } catch {}
1692
1832
  const creds = [];
1693
1833
  for (const f of files) {
1694
- const cred = await readJson(join(configDir(), "workers", f));
1834
+ const cred = await readJson(join2(configDir(), "workers", f));
1695
1835
  if (cred !== null && cred.server === server)
1696
1836
  creds.push(cred);
1697
1837
  }
1698
1838
  return creds;
1699
1839
  }
1700
- function checkoutRepo() {
1701
- try {
1702
- const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1703
- return parseGitHubRemote(url);
1704
- } catch {
1705
- return null;
1706
- }
1707
- }
1708
1840
  function requireRepo(explicit) {
1709
1841
  if (explicit) {
1710
1842
  const repo = parseGitHubRemote(explicit) ?? (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(explicit) ? explicit : null);
@@ -1803,6 +1935,8 @@ async function main(argv) {
1803
1935
  subject: { type: "string" },
1804
1936
  title: { type: "string" },
1805
1937
  exclude: { type: "string", multiple: true },
1938
+ force: { type: "boolean" },
1939
+ "no-sync": { type: "boolean" },
1806
1940
  help: { type: "boolean", short: "h" }
1807
1941
  }
1808
1942
  });
@@ -1813,7 +1947,17 @@ async function main(argv) {
1813
1947
  }
1814
1948
  const server = (str(values, "server") ?? process.env["AHFACT_URL"] ?? DEFAULT_SERVER).replace(/\/+$/, "");
1815
1949
  const asPerson = async () => client(server, (await loadPerson(server)).token);
1816
- const asWorker = async () => client(server, (await loadWorker(server, { worker: values.worker, harness: values.harness })).token);
1950
+ const workerCred = () => loadWorker(server, { worker: values.worker, harness: values.harness });
1951
+ const asWorker = async () => client(server, (await workerCred()).token);
1952
+ const syncOnce = async (cred, force) => {
1953
+ const api = client(server, cred.token);
1954
+ const turn = await api.worker.syncBegin({ force });
1955
+ if (!turn.due)
1956
+ return { synced: false, ...turn };
1957
+ const seen = await readGitHub(cred.repo);
1958
+ const result = await api.worker.syncReport(seen);
1959
+ return { synced: true, ...result };
1960
+ };
1817
1961
  const leaseArgs = [sub, ...rest];
1818
1962
  const leaseId = () => {
1819
1963
  const id = leaseArgs[0];
@@ -1835,6 +1979,23 @@ async function main(argv) {
1835
1979
  }
1836
1980
  return print({ signedOut: cred !== null, server });
1837
1981
  }
1982
+ case "setup":
1983
+ case "check": {
1984
+ const root = repoRoot();
1985
+ if (root === null)
1986
+ throw new UsageError(`${command} runs in a Git checkout.`);
1987
+ const config = await loadRepoConfig(root);
1988
+ const hook = command;
1989
+ const script = config.hooks[hook];
1990
+ if (!script)
1991
+ throw new UsageError(`${root}/.ahfact.json has no "${hook}" hook.`);
1992
+ hint(`ahfact ${hook}: ${script}`);
1993
+ const result = await runHook(root, hook, script);
1994
+ print(result);
1995
+ if (!result.ok)
1996
+ process.exit(1);
1997
+ return;
1998
+ }
1838
1999
  case "whoami": {
1839
2000
  const cred = await readJson(personFile(server));
1840
2001
  const person = cred === null ? null : await client(server, cred.token).auth.me().catch(() => null);
@@ -1876,11 +2037,26 @@ async function main(argv) {
1876
2037
  return print(await (await asWorker()).worker.leases());
1877
2038
  case "next":
1878
2039
  return print(await (await asWorker()).worker.next({ wait: Number(values.wait ?? 0) }));
2040
+ case "sync": {
2041
+ const cred = await workerCred();
2042
+ return print(await syncOnce(cred, values.force === true));
2043
+ }
1879
2044
  case "wait": {
1880
- const api = await asWorker();
2045
+ const cred = await workerCred();
2046
+ const api = client(server, cred.token);
1881
2047
  const timeout = values.timeout === undefined ? Infinity : Number(values.timeout) * 1000;
1882
2048
  const deadline = Date.now() + timeout;
1883
2049
  for (;; ) {
2050
+ if (!values["no-sync"]) {
2051
+ try {
2052
+ const result = await syncOnce(cred, false);
2053
+ if (result.synced && result.applied.length > 0) {
2054
+ hint(`ahfact: synced GitHub: ${result.applied.map((a) => `${a.type} ${a.subject}`).join(", ")}`);
2055
+ }
2056
+ } catch (error) {
2057
+ hint(`ahfact: sync failed: ${error instanceof Error ? error.message : String(error)}`);
2058
+ }
2059
+ }
1884
2060
  const left = Math.ceil((deadline - Date.now()) / 1000);
1885
2061
  const claim = await api.worker.next({ wait: Math.max(0, Math.min(55, left)) });
1886
2062
  if (claim !== null)
@@ -1930,7 +2106,7 @@ async function main(argv) {
1930
2106
  try {
1931
2107
  await main(process.argv.slice(2));
1932
2108
  } catch (error) {
1933
- if (error instanceof UsageError) {
2109
+ if (error instanceof UsageError || error instanceof ConfigError) {
1934
2110
  hint(`ahfact: ${error.message}`);
1935
2111
  process.exit(2);
1936
2112
  }
@@ -1943,5 +2119,9 @@ try {
1943
2119
  hint(`ahfact: cannot reach the factory: ${error.message}`);
1944
2120
  process.exit(3);
1945
2121
  }
2122
+ if (error instanceof Error && error.message.startsWith("gh ")) {
2123
+ hint(`ahfact: ${error.message}`);
2124
+ process.exit(4);
2125
+ }
1946
2126
  throw error;
1947
2127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrohacker/factory",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "ahfact: the command a worker and its subagents use to take and report on jobs in the Astrohacker Factory.",
5
5
  "type": "module",
6
6
  "bin": {