@higherdev/cli 0.34.0 → 0.35.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 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,114 @@
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
+ export function actionsRunnerSystemdUnit(input) {
26
+ return `[Unit]
27
+ Description=HDX GitHub Actions runner${input.repo ? ` for ${input.repo}` : ""}
28
+ After=network-online.target
29
+ Wants=network-online.target
30
+
31
+ [Service]
32
+ Type=simple
33
+ WorkingDirectory=${unitPath(input.runnerDir)}
34
+ Environment=${unitEnvironment("PATH", input.path)}
35
+ ExecStart=${unitPath(path.join(input.runnerDir, "bin/runsvc.sh"))}
36
+ Restart=always
37
+ RestartSec=5
38
+ KillMode=process
39
+
40
+ [Install]
41
+ WantedBy=default.target
42
+ `;
43
+ }
44
+ async function exists(target) {
45
+ try {
46
+ await access(target);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function runnerArchitecture(value) {
54
+ if (value === "x64")
55
+ return "x64";
56
+ if (value === "arm64")
57
+ return "arm64";
58
+ throw new Error(`GitHub Actions runner does not support architecture ${value}.`);
59
+ }
60
+ export async function installActionsRunner(options, deps = {}) {
61
+ const platform = deps.platform ?? process.platform;
62
+ if (platform !== "linux")
63
+ throw new Error("hd host runner-install must run on the Linux host.");
64
+ if (process.getuid?.() === 0 && !deps.platform)
65
+ throw new Error("Run hd host runner-install as the runner user, not root.");
66
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(options.repo))
67
+ throw new Error("--repo must be OWNER/NAME.");
68
+ if (!options.token.trim())
69
+ throw new Error("--token is required.");
70
+ const run = deps.exec ?? ((file, args, commandOptions = {}) => exec(file, args, {
71
+ ...commandOptions, encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
72
+ }));
73
+ const home = deps.home ?? homedir();
74
+ const runnerDir = options.runnerDir ?? actionsRunnerDirectory(home, options.repo);
75
+ const unitName = actionsRunnerUnitName(options.repo);
76
+ const unitPath = options.unitPath ?? path.join(home, ".config/systemd/user", unitName);
77
+ const pathValue = options.path ?? process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
78
+ for (const tool of ACTIONS_RUNNER_TOOLS) {
79
+ await run(tool, ["--version"], { env: process.env }).catch(() => {
80
+ throw new Error(`${tool} is required before installing the Actions runner.`);
81
+ });
82
+ }
83
+ await mkdir(runnerDir, { recursive: true });
84
+ const configured = await exists(path.join(runnerDir, ".runner"));
85
+ if (!await exists(path.join(runnerDir, "bin/runsvc.sh"))) {
86
+ const release = (await run("gh", ["api", "repos/actions/runner/releases/latest", "--jq", ".tag_name"])).stdout.trim();
87
+ if (!/^v\d+\.\d+\.\d+$/.test(release))
88
+ throw new Error(`Invalid Actions runner release ${release || "(empty)"}.`);
89
+ const version = release.slice(1);
90
+ const archive = `actions-runner-linux-${runnerArchitecture(deps.architecture ?? arch())}-${version}.tar.gz`;
91
+ await run("gh", ["release", "download", release, "--repo", "actions/runner", "--pattern", archive,
92
+ "--dir", runnerDir, "--clobber"]);
93
+ await run("tar", ["-xzf", path.join(runnerDir, archive), "-C", runnerDir]);
94
+ await unlink(path.join(runnerDir, archive));
95
+ }
96
+ if (!configured) {
97
+ await run(path.join(runnerDir, "config.sh"), ["--unattended", "--replace",
98
+ "--url", `https://github.com/${options.repo}`, "--token", options.token, "--name", options.host,
99
+ "--labels", `hdx,${options.host}`, "--work", "_work"], { cwd: runnerDir, env: process.env });
100
+ }
101
+ else {
102
+ // The runner writes .runner with a UTF-8 byte-order mark, which JSON.parse rejects.
103
+ const registration = JSON.parse((await readFile(path.join(runnerDir, ".runner"), "utf8")).replace(/^\uFEFF/, ""));
104
+ if (registration.agentName !== options.host || !registration.gitHubUrl?.toLowerCase().includes(`/${options.repo.toLowerCase()}`)) {
105
+ throw new Error(`Existing Actions runner in ${runnerDir} belongs to another host or repository.`);
106
+ }
107
+ }
108
+ await mkdir(path.dirname(unitPath), { recursive: true });
109
+ await writeFile(unitPath, actionsRunnerSystemdUnit({ runnerDir, path: pathValue, repo: options.repo }), "utf8");
110
+ await run("systemctl", ["--user", "daemon-reload"]);
111
+ await run("systemctl", ["--user", "enable", "--now", unitName]);
112
+ return [`GitHub Actions runner ${options.host} registered to ${options.repo}.`,
113
+ `labels: self-hosted, hdx, ${options.host}`, `systemd unit: ${unitPath}`];
114
+ }
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 roll | env ls | set | rm")} roll a host or edit host env`,
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`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",