@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.
- package/dist/ahfact.js +202 -22
- 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 {
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
1814
|
+
return JSON.parse(await readFile2(file, "utf8"));
|
|
1675
1815
|
} catch {
|
|
1676
1816
|
return null;
|
|
1677
1817
|
}
|
|
1678
1818
|
}
|
|
1679
|
-
var personFile = (server) =>
|
|
1680
|
-
var workerFile = (worker) =>
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
}
|