@higherdev/cli 0.34.0 → 0.36.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/README.md +1 -0
- package/dist/actions-runner.js +126 -0
- package/dist/api.js +3 -0
- package/dist/host.js +10 -1
- package/dist/index.js +52 -3
- package/dist/out.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ workspace-map config shapes are migrated automatically when they are read.
|
|
|
50
50
|
| `hd env set NAME=VALUE [NAME=VALUE...]` | Set workspace environment variables |
|
|
51
51
|
| `hd env rm NAME` | Remove a workspace environment variable |
|
|
52
52
|
| `hd host roll [--host HOST] [--address ADDR] [--checkout DIR]` | Pull, install, and restart a runner over SSH, then watch drain |
|
|
53
|
+
| `hd host runner-install` | Register the default host as the org's `self-hosted, hdx` Actions runner |
|
|
53
54
|
| `hd host env ls` | List environment variable names on the workspace's default host |
|
|
54
55
|
| `hd host env set NAME=VALUE [NAME=VALUE...]` | Set host-only environment variables |
|
|
55
56
|
| `hd host env rm NAME` | Remove a host-only environment variable |
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, arch } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
export const ACTIONS_RUNNER_TOOLS = ["node", "pnpm", "gh", "supabase", "docker"];
|
|
8
|
+
/** One runner per repository: GitHub only shares runners across repositories for organizations. */
|
|
9
|
+
export function actionsRunnerSlug(repo) {
|
|
10
|
+
return repo.replace("/", "-").toLowerCase().replace(/[^a-z0-9.-]+/g, "-");
|
|
11
|
+
}
|
|
12
|
+
export function actionsRunnerUnitName(repo) {
|
|
13
|
+
return `hdx-actions-runner-${actionsRunnerSlug(repo)}.service`;
|
|
14
|
+
}
|
|
15
|
+
export function actionsRunnerDirectory(home, repo) {
|
|
16
|
+
return path.join(home, ".local/share/hdx/actions-runner", actionsRunnerSlug(repo));
|
|
17
|
+
}
|
|
18
|
+
/** systemd takes WorkingDirectory= and ExecStart= unquoted; only % needs escaping there. */
|
|
19
|
+
function unitPath(value) {
|
|
20
|
+
return value.replace(/%/g, "%%");
|
|
21
|
+
}
|
|
22
|
+
function unitEnvironment(name, value) {
|
|
23
|
+
return `"${name}=${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A systemd user service inherits the supplementary groups the user manager had
|
|
27
|
+
* when it started, so a docker group granted later never reaches the runner.
|
|
28
|
+
* Starting through `sg <group>` picks the group up per membership in /etc/group.
|
|
29
|
+
*/
|
|
30
|
+
export function actionsRunnerExecStart(runnerDir, viaGroup) {
|
|
31
|
+
const service = path.join(runnerDir, "bin/runsvc.sh");
|
|
32
|
+
return viaGroup ? `/usr/bin/sg ${viaGroup} -c "${unitPath(service)}"` : unitPath(service);
|
|
33
|
+
}
|
|
34
|
+
export function actionsRunnerSystemdUnit(input) {
|
|
35
|
+
return `[Unit]
|
|
36
|
+
Description=HDX GitHub Actions runner${input.repo ? ` for ${input.repo}` : ""}
|
|
37
|
+
After=network-online.target
|
|
38
|
+
Wants=network-online.target
|
|
39
|
+
|
|
40
|
+
[Service]
|
|
41
|
+
Type=simple
|
|
42
|
+
WorkingDirectory=${unitPath(input.runnerDir)}
|
|
43
|
+
Environment=${unitEnvironment("PATH", input.path)}
|
|
44
|
+
ExecStart=${actionsRunnerExecStart(input.runnerDir, input.viaGroup)}
|
|
45
|
+
Restart=always
|
|
46
|
+
RestartSec=5
|
|
47
|
+
KillMode=process
|
|
48
|
+
|
|
49
|
+
[Install]
|
|
50
|
+
WantedBy=default.target
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
async function exists(target) {
|
|
54
|
+
try {
|
|
55
|
+
await access(target);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function runnerArchitecture(value) {
|
|
63
|
+
if (value === "x64")
|
|
64
|
+
return "x64";
|
|
65
|
+
if (value === "arm64")
|
|
66
|
+
return "arm64";
|
|
67
|
+
throw new Error(`GitHub Actions runner does not support architecture ${value}.`);
|
|
68
|
+
}
|
|
69
|
+
export async function installActionsRunner(options, deps = {}) {
|
|
70
|
+
const platform = deps.platform ?? process.platform;
|
|
71
|
+
if (platform !== "linux")
|
|
72
|
+
throw new Error("hd host runner-install must run on the Linux host.");
|
|
73
|
+
if (process.getuid?.() === 0 && !deps.platform)
|
|
74
|
+
throw new Error("Run hd host runner-install as the runner user, not root.");
|
|
75
|
+
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(options.repo))
|
|
76
|
+
throw new Error("--repo must be OWNER/NAME.");
|
|
77
|
+
if (!options.token.trim())
|
|
78
|
+
throw new Error("--token is required.");
|
|
79
|
+
const run = deps.exec ?? ((file, args, commandOptions = {}) => exec(file, args, {
|
|
80
|
+
...commandOptions, encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
81
|
+
}));
|
|
82
|
+
const home = deps.home ?? homedir();
|
|
83
|
+
const runnerDir = options.runnerDir ?? actionsRunnerDirectory(home, options.repo);
|
|
84
|
+
const unitName = actionsRunnerUnitName(options.repo);
|
|
85
|
+
const unitPath = options.unitPath ?? path.join(home, ".config/systemd/user", unitName);
|
|
86
|
+
const pathValue = options.path ?? process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
87
|
+
for (const tool of ACTIONS_RUNNER_TOOLS) {
|
|
88
|
+
await run(tool, ["--version"], { env: process.env }).catch(() => {
|
|
89
|
+
throw new Error(`${tool} is required before installing the Actions runner.`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await mkdir(runnerDir, { recursive: true });
|
|
93
|
+
const configured = await exists(path.join(runnerDir, ".runner"));
|
|
94
|
+
if (!await exists(path.join(runnerDir, "bin/runsvc.sh"))) {
|
|
95
|
+
const release = (await run("gh", ["api", "repos/actions/runner/releases/latest", "--jq", ".tag_name"])).stdout.trim();
|
|
96
|
+
if (!/^v\d+\.\d+\.\d+$/.test(release))
|
|
97
|
+
throw new Error(`Invalid Actions runner release ${release || "(empty)"}.`);
|
|
98
|
+
const version = release.slice(1);
|
|
99
|
+
const archive = `actions-runner-linux-${runnerArchitecture(deps.architecture ?? arch())}-${version}.tar.gz`;
|
|
100
|
+
await run("gh", ["release", "download", release, "--repo", "actions/runner", "--pattern", archive,
|
|
101
|
+
"--dir", runnerDir, "--clobber"]);
|
|
102
|
+
await run("tar", ["-xzf", path.join(runnerDir, archive), "-C", runnerDir]);
|
|
103
|
+
await unlink(path.join(runnerDir, archive));
|
|
104
|
+
}
|
|
105
|
+
if (!configured) {
|
|
106
|
+
await run(path.join(runnerDir, "config.sh"), ["--unattended", "--replace",
|
|
107
|
+
"--url", `https://github.com/${options.repo}`, "--token", options.token, "--name", options.host,
|
|
108
|
+
"--labels", `hdx,${options.host}`, "--work", "_work"], { cwd: runnerDir, env: process.env });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// The runner writes .runner with a UTF-8 byte-order mark, which JSON.parse rejects.
|
|
112
|
+
const registration = JSON.parse((await readFile(path.join(runnerDir, ".runner"), "utf8")).replace(/^\uFEFF/, ""));
|
|
113
|
+
if (registration.agentName !== options.host || !registration.gitHubUrl?.toLowerCase().includes(`/${options.repo.toLowerCase()}`)) {
|
|
114
|
+
throw new Error(`Existing Actions runner in ${runnerDir} belongs to another host or repository.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Jobs with service containers need the Docker socket; run through the docker group when the user has it.
|
|
118
|
+
const groups = (await run("id", ["-nG"]).catch(() => ({ stdout: "" }))).stdout.trim().split(/\s+/);
|
|
119
|
+
const viaGroup = groups.includes("docker") && await exists("/usr/bin/sg") ? "docker" : null;
|
|
120
|
+
await mkdir(path.dirname(unitPath), { recursive: true });
|
|
121
|
+
await writeFile(unitPath, actionsRunnerSystemdUnit({ runnerDir, path: pathValue, repo: options.repo, viaGroup }), "utf8");
|
|
122
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
123
|
+
await run("systemctl", ["--user", "enable", "--now", unitName]);
|
|
124
|
+
return [`GitHub Actions runner ${options.host} registered to ${options.repo}.`,
|
|
125
|
+
`labels: self-hosted, hdx, ${options.host}`, `systemd unit: ${unitPath}${viaGroup ? ` (via sg ${viaGroup})` : ""}`];
|
|
126
|
+
}
|
package/dist/api.js
CHANGED
|
@@ -137,6 +137,9 @@ export async function setHostEnv(host, name, value, config = loadConfig()) {
|
|
|
137
137
|
export async function removeHostEnv(host, name, config = loadConfig()) {
|
|
138
138
|
return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
|
|
139
139
|
}
|
|
140
|
+
export async function setHostCi(host, owner, config = loadConfig()) {
|
|
141
|
+
return request(config, "PATCH", `/api/hosts/${encodeURIComponent(host)}/ci`, { ci: "self-hosted", owner });
|
|
142
|
+
}
|
|
140
143
|
export async function getRoadmap(config = loadConfig()) {
|
|
141
144
|
return request(config, "GET", `/api/w/${config.slug}/roadmap`);
|
|
142
145
|
}
|
package/dist/host.js
CHANGED
|
@@ -64,6 +64,15 @@ export function formatDrainStatus(live, until, now = Date.now()) {
|
|
|
64
64
|
const runs = live === 1 ? "1 run" : `${live} runs`;
|
|
65
65
|
return `draining: ${runs}, up to ${minutes}m`;
|
|
66
66
|
}
|
|
67
|
+
export function formatCiMinutes(minutes) {
|
|
68
|
+
const reset = new Date(minutes.resets_at).toLocaleDateString("en-US", {
|
|
69
|
+
month: "short", day: "numeric", timeZone: "UTC",
|
|
70
|
+
});
|
|
71
|
+
const share = minutes.total_runs
|
|
72
|
+
? `; ${Math.round(minutes.box_runs / minutes.total_runs * 100)}% of this month's runs used the box` : "";
|
|
73
|
+
return `GitHub Actions: ${minutes.used.toLocaleString("en-US")} of ${minutes.included.toLocaleString("en-US")}`
|
|
74
|
+
+ ` hosted minutes used, resets ${reset}${share}`;
|
|
75
|
+
}
|
|
67
76
|
export function hostDrainFromStats(stats) {
|
|
68
77
|
if (!stats || typeof stats !== "object" || Array.isArray(stats))
|
|
69
78
|
return null;
|
|
@@ -174,7 +183,7 @@ export function hostRollCommand(checkout) {
|
|
|
174
183
|
"npm i -g @higherdev/cli@latest",
|
|
175
184
|
].join(" && ");
|
|
176
185
|
}
|
|
177
|
-
async function defaultSsh(address, command) {
|
|
186
|
+
export async function defaultSsh(address, command) {
|
|
178
187
|
return execFileAsync("ssh", ["-o", "BatchMode=yes", address, "bash", "-lc", command], { encoding: "utf8" });
|
|
179
188
|
}
|
|
180
189
|
async function exists(file) {
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import { approveEpic, answerDecision, cancelTicket, cancelRun, mergeTicket, createEpic, deleteAgent, deleteEpic, getStatus, getRoadmap, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateEpic, updateCaps, } from "./api.js";
|
|
5
|
-
import { formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
|
|
4
|
+
import { approveEpic, answerDecision, cancelTicket, cancelRun, mergeTicket, createEpic, deleteAgent, deleteEpic, getStatus, getRoadmap, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, setHostCi, showTicket, updateAgent, updateEpic, updateCaps, } from "./api.js";
|
|
5
|
+
import { defaultSsh, formatCiMinutes, formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
|
|
6
|
+
import { installActionsRunner } from "./actions-runner.js";
|
|
6
7
|
import { login, parseLoginFlags } from "./login.js";
|
|
7
8
|
import { loadConfig } from "./config.js";
|
|
8
9
|
import { beginUpdateCheck, pendingUpdateNotice, runUpdate, shouldUpdateCheck, } from "./update-check.js";
|
|
@@ -73,6 +74,8 @@ async function cmdStatus() {
|
|
|
73
74
|
if (signedOut.length) {
|
|
74
75
|
console.log(`${c.bold("Host")} ${signedOut.map((row) => c.yellow(row.reason)).join("\n ")}\n`);
|
|
75
76
|
}
|
|
77
|
+
if (data.host?.ci_minutes)
|
|
78
|
+
console.log(`${c.bold("CI")} ${formatCiMinutes(data.host.ci_minutes)}\n`);
|
|
76
79
|
const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
|
|
77
80
|
?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
|
|
78
81
|
.map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
|
|
@@ -582,7 +585,53 @@ async function cmdHost(argv, deps = {}) {
|
|
|
582
585
|
await hostRoll(parseHostRollFlags([action, ...args].filter((value) => Boolean(value))), deps);
|
|
583
586
|
return;
|
|
584
587
|
}
|
|
585
|
-
const usage = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR] | env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
|
|
588
|
+
const usage = "usage: hd host runner-install [--repo OWNER/NAME --token TOKEN] | roll [--host HOST] [--address ADDR] [--checkout DIR] | env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
|
|
589
|
+
if (scope === "runner-install") {
|
|
590
|
+
const parsed = flags([action, ...args].filter((value) => Boolean(value)));
|
|
591
|
+
if (parsed.rest.length || parsed.bools.size)
|
|
592
|
+
fail(usage);
|
|
593
|
+
if (parsed.opts.repo || parsed.opts.token) {
|
|
594
|
+
// Host mode: register one runner for one repository with a token minted by the owner.
|
|
595
|
+
// With --host it needs no hd login on the host, so the first runner can be bootstrapped
|
|
596
|
+
// from a checkout before any published CLI can reach the host.
|
|
597
|
+
if (!parsed.opts.repo || !parsed.opts.token)
|
|
598
|
+
fail(usage);
|
|
599
|
+
if (process.platform !== "linux" && !deps.installActionsRunner)
|
|
600
|
+
fail("Run this form on the Linux host.");
|
|
601
|
+
const host = parsed.opts.host ?? (await getStatus()).host?.id ?? (await getStatus()).workspace.default_host;
|
|
602
|
+
const lines = await (deps.installActionsRunner ?? installActionsRunner)({
|
|
603
|
+
host, repo: parsed.opts.repo, token: parsed.opts.token,
|
|
604
|
+
});
|
|
605
|
+
console.log(lines.join("\n"));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const status = await getStatus();
|
|
609
|
+
const host = parsed.opts.host ?? status.host?.id ?? status.workspace.default_host;
|
|
610
|
+
// Owner mode: GitHub shares runners across repositories only for organizations, and a
|
|
611
|
+
// registration token needs admin on the repository, so mint one per workspace repository
|
|
612
|
+
// with the operator's gh here and hand each to the host over SSH. Tokens expire in an hour.
|
|
613
|
+
const address = status.host?.ssh_address;
|
|
614
|
+
if (!address)
|
|
615
|
+
fail(`hd host runner-install needs an SSH address for ${host}. Set RUNNER_SSH_ADDRESS on the host.`);
|
|
616
|
+
const workspaces = await listWorkspaces();
|
|
617
|
+
const repos = [...new Set(workspaces
|
|
618
|
+
.filter((workspace) => !("default_host" in workspace) || workspace.default_host === host)
|
|
619
|
+
.map((workspace) => workspace.repo))];
|
|
620
|
+
if (!repos.length)
|
|
621
|
+
fail(`No workspaces use host ${host}.`);
|
|
622
|
+
const gh = deps.gh ?? defaultGh;
|
|
623
|
+
for (const repo of repos) {
|
|
624
|
+
const token = (await gh(["api", "--method", "POST", `/repos/${repo}/actions/runners/registration-token`,
|
|
625
|
+
"--jq", ".token"])).trim();
|
|
626
|
+
if (!token)
|
|
627
|
+
fail(`GitHub did not return a runner registration token for ${repo}; you need admin on that repository.`);
|
|
628
|
+
const remote = await (deps.ssh ?? defaultSsh)(address, `hd --no-update-check host runner-install --host ${host} --repo ${repo} --token ${token}`);
|
|
629
|
+
console.log(remote.stdout.trim() || `Registered a runner for ${repo} on ${host}.`);
|
|
630
|
+
}
|
|
631
|
+
await setHostCi(host, repos[0].split("/")[0]);
|
|
632
|
+
console.log(`CI on ${host} enabled for ${repos.length} repositor${repos.length === 1 ? "y" : "ies"}; workflows move to [self-hosted, hdx] on the next reconcile.`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
586
635
|
if (scope !== "env")
|
|
587
636
|
fail(usage);
|
|
588
637
|
const host = (await getStatus()).workspace.default_host;
|
package/dist/out.js
CHANGED
|
@@ -73,7 +73,7 @@ export function usage() {
|
|
|
73
73
|
` ${c.blue("hd agents [add | rm | set]")} manage agents`,
|
|
74
74
|
` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
|
|
75
75
|
` ${c.blue("hd env ls [--vercel] | set | rm")} workspace environment`,
|
|
76
|
-
` ${c.blue("hd host
|
|
76
|
+
` ${c.blue("hd host runner-install | roll | env")} install CI, roll, or edit host env`,
|
|
77
77
|
` ${c.blue("hd logs KEY [-f]")} run events`,
|
|
78
78
|
` ${c.blue("hd msg KEY TEXT")} message a builder`,
|
|
79
79
|
` ${c.blue("hd attach PATH --ticket KEY | --to ROLE")} attach a file`,
|