@higherdev/cli 0.3.0 → 0.5.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 +47 -172
- package/dist/api.js +85 -0
- package/dist/config.js +80 -0
- package/dist/host.js +179 -0
- package/dist/index.js +259 -9257
- package/dist/out/format.js +96 -0
- package/dist/out/theme.js +73 -0
- package/dist/out.js +76 -0
- package/dist/tui/App.js +418 -0
- package/dist/tui/Banner.js +301 -0
- package/dist/tui/Bubble.js +12 -0
- package/dist/tui/Dashboard.js +206 -0
- package/dist/tui/Decision.js +48 -0
- package/dist/tui/Help.js +30 -0
- package/dist/tui/Panels.js +195 -0
- package/dist/tui/Settings.js +45 -0
- package/dist/tui/Splash.js +15 -0
- package/dist/tui/TextInput.js +137 -0
- package/dist/tui/alert.js +19 -0
- package/dist/tui/bounded.js +37 -0
- package/dist/tui/capability.js +4 -0
- package/dist/tui/data.js +165 -0
- package/dist/tui/height.js +58 -0
- package/dist/tui/launch.js +20 -0
- package/dist/tui/layout.js +54 -0
- package/dist/tui/parse.js +48 -0
- package/dist/tui/settings-model.js +76 -0
- package/dist/tui/stream.js +122 -0
- package/dist/tui/theme.js +52 -0
- package/dist/tui/workspace-load.js +18 -0
- package/package.json +8 -34
package/README.md
CHANGED
|
@@ -1,176 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# HigherDEV CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
tickets, runs, and spend without a browser.
|
|
3
|
+
Operator controls for HDX workspaces, tickets, agents, and runner hosts.
|
|
5
4
|
|
|
6
5
|
## Install
|
|
7
6
|
|
|
8
|
-
```
|
|
9
|
-
npm
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
`hd`
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
hd
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
It signs in as you, with the same emailed six-digit code the web app uses, and
|
|
54
|
-
talks to Supabase directly under RLS. No service key lives on your laptop, and
|
|
55
|
-
every write records an audit event naming you, so a CLI change reads the same as
|
|
56
|
-
a click. Live views are Realtime subscriptions, not polling.
|
|
57
|
-
|
|
58
|
-
## Two ways to talk to it
|
|
59
|
-
|
|
60
|
-
`hd ask` reaches the **orchestrator**, which runs on the box and is in charge of
|
|
61
|
-
getting work in flight to finish. Ask it why something is stuck or tell it to
|
|
62
|
-
reshuffle a workstream. It cannot change agent settings, and that is enforced by
|
|
63
|
-
its action schema rather than by its prompt.
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
hd ask "why is HD-12 stuck?"
|
|
67
|
-
hd msg HD-12 --to builder "also rename the old column" --interrupt
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
`hd plan` talks to **your own model**, here on your machine, on your ChatGPT
|
|
71
|
-
subscription. It is not a worker in a workspace and not a row in the agent
|
|
72
|
-
registry: nothing on the runner dispatches to it, and it reaches the whole
|
|
73
|
-
platform. `hd brain` shows which model it is and connects the subscription the
|
|
74
|
-
first time. It reads the repo, decides what should be built, files an epic,
|
|
75
|
-
and can tune the platform itself: models, effort, routing notes, prompt addenda,
|
|
76
|
-
conventions, budgets. Creating an epic fires `epic.created`, which the
|
|
77
|
-
orchestrator already triggers on, so the handoff needs no glue.
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
hd brain # which model, and connect it the first time
|
|
81
|
-
hd plan # a conversation: type, it answers, keep going
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Bare `hd plan` on a terminal is a back-and-forth. Every turn after the first
|
|
85
|
-
continues the same Codex session, so it remembers what you already said. A blank
|
|
86
|
-
line or `/exit` leaves, and `hd plan --resume "..."` picks the thread back up
|
|
87
|
-
later.
|
|
88
|
-
|
|
89
|
-
Give it a request on the command line instead and it runs one turn and exits,
|
|
90
|
-
which is what a script wants:
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
hd plan "assess the call-intel code and plan what is missing"
|
|
94
|
-
hd plan --read-only "what would you change about the agent lineup?"
|
|
95
|
-
hd plan --once "file the epic we discussed"
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
### How it reaches the model
|
|
99
|
-
|
|
100
|
-
`hd` calls the ChatGPT backend directly with your subscription credentials and
|
|
101
|
-
runs its own agent loop over its own tools, in process. It does not spawn
|
|
102
|
-
`codex exec`. That matters: a subprocess made Codex's coding agent the brain, so
|
|
103
|
-
it owned the system prompt, it had a shell, and a greeting took twenty seconds.
|
|
104
|
-
Direct, a turn answers in about two, `hd` owns the prompt, there is no shell in
|
|
105
|
-
the path, and text streams as it is written.
|
|
106
|
-
|
|
107
|
-
The credential comes from `~/.codex/auth.json`, because the Codex CLI is what
|
|
108
|
-
performs the browser login. Nothing else about Codex is involved. `hd connect`
|
|
109
|
-
runs that login for you; `hd` never sees or stores the token, and renews the
|
|
110
|
-
session automatically rather than asking you to sign in again.
|
|
111
|
-
|
|
112
|
-
`hd plan` **refuses to start** unless that session is a real ChatGPT one, since
|
|
113
|
-
the platform runs on subscriptions and never per-token APIs. It distinguishes
|
|
114
|
-
connected, signed in with an API key (which bills per token), signed out, and
|
|
115
|
-
not installed. `--allow-api-billing` overrides the refusal when you mean it.
|
|
116
|
-
|
|
117
|
-
The architect reads code through `list_files`, `read_file`, and `search_code`,
|
|
118
|
-
scoped to the workspace checkout. Read only, no shell, no path escape.
|
|
119
|
-
|
|
120
|
-
`hd mcp` is the same platform as MCP tools on stdio. `hd plan` attaches it to
|
|
121
|
-
Codex for the invocation, with no global config edit. Attach it to Claude Code or
|
|
122
|
-
give Mel the read-only half:
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
hd mcp --list # the 24 tools, with what each does
|
|
126
|
-
hd mcp --read-only # safe to hand to anything
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
The architect does not go through MCP: its tools are the same functions, called
|
|
130
|
-
in process. `hd mcp` exists so anything else can reach them.
|
|
131
|
-
|
|
132
|
-
## Everything else
|
|
133
|
-
|
|
134
|
-
```
|
|
135
|
-
hd live dashboard hd new "title" --area web/auth
|
|
136
|
-
hd status one-screen summary hd set HD-12 --agent Codex
|
|
137
|
-
hd board kanban by status hd block HD-12 HD-9
|
|
138
|
-
hd ls --stuck why nothing is moving hd take HD-12 / hd handback HD-12
|
|
139
|
-
hd show HD-12 one ticket in full hd cancel HD-12 / hd kill HD-12
|
|
140
|
-
hd watch HD-12 live run transcript hd epic new "title" spec.md
|
|
141
|
-
hd logs <run> -f one run, following hd epic replan <id>
|
|
142
|
-
hd agents live agent state hd orchestrate
|
|
143
|
-
hd feed live activity hd decide <id> "answer"
|
|
144
|
-
hd inbox decisions and messages hd pause / hd resume [--all|-p claude]
|
|
145
|
-
hd cost spend, by day and more hd agent set Codex --model gpt-5.6-sol
|
|
146
|
-
hd hosts runner health hd conventions --edit
|
|
147
|
-
hd connect --check Codex connection hd upgrade [--check]
|
|
148
|
-
hd epic show <id> dependency waves hd attach HD-12 shot.png
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
Every read takes `--json` and every write is non-interactive when given flags, so
|
|
152
|
-
Claude Code and Mel can drive the board over SSH. Colour follows `NO_COLOR` and
|
|
153
|
-
turns itself off when stdout is not a terminal.
|
|
154
|
-
|
|
155
|
-
## Onboarding a new host
|
|
156
|
-
|
|
157
|
-
`hd init` still does host onboarding: it writes the runner env file, registers
|
|
158
|
-
the host, installs the launchd plist, and checks each provider CLI is authed. It
|
|
159
|
-
delegates to the runner package, so the box's systemd service never pulls in the
|
|
160
|
-
CLI's UI dependencies.
|
|
161
|
-
|
|
162
|
-
```bash
|
|
163
|
-
hd init --host seb-mac --owner-email sebastian@higherops.io --install-launchd
|
|
164
|
-
hd login
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
## Development
|
|
168
|
-
|
|
169
|
-
```bash
|
|
170
|
-
pnpm --filter @higherdev/cli dev -- status # run from source
|
|
171
|
-
pnpm --filter @higherdev/cli test
|
|
172
|
-
pnpm --filter @higherdev/cli typecheck
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
`HIGHERDEV_CONFIG_DIR` points the config and session somewhere else, which is
|
|
176
|
-
how to keep a scratch login separate from your real one.
|
|
7
|
+
```sh
|
|
8
|
+
npm install --global @higherdev/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Create `~/.config/hd/config.json` with an existing workspace API key:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"current": "workspace",
|
|
16
|
+
"workspaces": {
|
|
17
|
+
"workspace": {
|
|
18
|
+
"url": "https://hdx-higher-ops.vercel.app",
|
|
19
|
+
"api_key": "hdx_..."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The previous single-workspace config shape is migrated automatically when it is read.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
| Command | Purpose |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `hd` | Open the live TUI when attached to a terminal |
|
|
32
|
+
| `hd --help` | Show the HigherDEV banner and usage |
|
|
33
|
+
| `hd status` | Show workspace, ticket, run, and decision status |
|
|
34
|
+
| `hd ticket list` | List tickets |
|
|
35
|
+
| `hd ticket show KEY` | Show one ticket |
|
|
36
|
+
| `hd ticket new --title TITLE [options]` | Create a ticket |
|
|
37
|
+
| `hd workspace new --name NAME --repo OWNER/NAME [options]` | Create a paused workspace |
|
|
38
|
+
| `hd agents` | List agents |
|
|
39
|
+
| `hd agents set ROLE --provider P --model M [--effort E]` | Update an agent |
|
|
40
|
+
| `hd logs KEY [-f]` | Show or follow run events |
|
|
41
|
+
| `hd msg KEY "message" [--interrupt]` | Message a builder |
|
|
42
|
+
| `hd decide` | List open decisions |
|
|
43
|
+
| `hd decide ID --answer TEXT` | Answer a decision |
|
|
44
|
+
| `hd on` / `hd off` | Turn the workspace on or off |
|
|
45
|
+
| `hd pause` / `hd resume` | Pause or resume the workspace |
|
|
46
|
+
| `hd init [options]` | Configure this host and runner service |
|
|
47
|
+
| `hd upgrade [options]` | Refresh this host configuration |
|
|
48
|
+
|
|
49
|
+
Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/decide`, `/agents`,
|
|
50
|
+
`/settings`, `/workspace`, `/feed`, `/orchestrator`, `/refresh`, `/help`, or
|
|
51
|
+
`/exit`. The display refreshes from the HDX API every five seconds.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
async function request(config, method, path, body) {
|
|
3
|
+
const response = await fetch(`${config.url}${path}`, {
|
|
4
|
+
method,
|
|
5
|
+
headers: {
|
|
6
|
+
authorization: `Bearer ${config.api_key}`,
|
|
7
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
8
|
+
},
|
|
9
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
10
|
+
});
|
|
11
|
+
const text = await response.text();
|
|
12
|
+
let parsed = null;
|
|
13
|
+
try {
|
|
14
|
+
parsed = text ? JSON.parse(text) : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`hd: ${response.status} ${text || response.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const err = parsed;
|
|
21
|
+
throw new Error(`hd: ${response.status} ${err.error ?? text}`);
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
export async function getStatus(config = loadConfig()) {
|
|
26
|
+
return request(config, "GET", `/api/w/${config.slug}/status`);
|
|
27
|
+
}
|
|
28
|
+
export async function listTickets(config = loadConfig()) {
|
|
29
|
+
return request(config, "GET", `/api/w/${config.slug}/tickets`);
|
|
30
|
+
}
|
|
31
|
+
export async function showTicket(key, config = loadConfig()) {
|
|
32
|
+
return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}`);
|
|
33
|
+
}
|
|
34
|
+
export async function createTicket(fields, config = loadConfig()) {
|
|
35
|
+
return request(config, "POST", `/api/w/${config.slug}/tickets`, fields);
|
|
36
|
+
}
|
|
37
|
+
export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
|
|
38
|
+
const query = new URLSearchParams();
|
|
39
|
+
if (afterAt)
|
|
40
|
+
query.set("after_at", afterAt);
|
|
41
|
+
if (afterId)
|
|
42
|
+
query.set("after_id", afterId);
|
|
43
|
+
const suffix = query.size ? `?${query.toString()}` : "";
|
|
44
|
+
return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events${suffix}`);
|
|
45
|
+
}
|
|
46
|
+
export async function postMessage(fields, config = loadConfig()) {
|
|
47
|
+
return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
|
|
48
|
+
}
|
|
49
|
+
export async function answerDecision(id, answer_md, config = loadConfig()) {
|
|
50
|
+
return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
|
|
51
|
+
}
|
|
52
|
+
export async function setPaused(paused, config = loadConfig()) {
|
|
53
|
+
const path = paused ? `/api/w/${config.slug}/pause` : `/api/w/${config.slug}/resume`;
|
|
54
|
+
return request(config, "POST", path);
|
|
55
|
+
}
|
|
56
|
+
export async function createWorkspace(fields, config = loadConfig()) {
|
|
57
|
+
const result = await request(config, "POST", "/api/workspaces", fields);
|
|
58
|
+
return { ...result, url: config.url };
|
|
59
|
+
}
|
|
60
|
+
export async function listAgents(config = loadConfig()) {
|
|
61
|
+
return request(config, "GET", `/api/w/${config.slug}/agents`);
|
|
62
|
+
}
|
|
63
|
+
export async function updateAgent(id, fields, config = loadConfig()) {
|
|
64
|
+
return request(config, "PATCH", `/api/w/${config.slug}/agents`, { id, ...fields });
|
|
65
|
+
}
|
|
66
|
+
export async function listEpics(config = loadConfig()) {
|
|
67
|
+
return request(config, "GET", `/api/w/${config.slug}/epics`);
|
|
68
|
+
}
|
|
69
|
+
export async function listMessages(options = {}, config = loadConfig()) {
|
|
70
|
+
const query = new URLSearchParams();
|
|
71
|
+
if (options.ticketId)
|
|
72
|
+
query.set("ticket_id", options.ticketId);
|
|
73
|
+
if (options.since)
|
|
74
|
+
query.set("since", options.since);
|
|
75
|
+
if (options.limit)
|
|
76
|
+
query.set("limit", String(options.limit));
|
|
77
|
+
const suffix = query.size ? `?${query.toString()}` : "";
|
|
78
|
+
return request(config, "GET", `/api/w/${config.slug}/messages${suffix}`);
|
|
79
|
+
}
|
|
80
|
+
export async function updateCaps(provider_caps, config = loadConfig()) {
|
|
81
|
+
return request(config, "PATCH", `/api/w/${config.slug}/caps`, { provider_caps });
|
|
82
|
+
}
|
|
83
|
+
export async function listFeed(config = loadConfig()) {
|
|
84
|
+
return request(config, "GET", `/api/w/${config.slug}/feed`);
|
|
85
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
export function configPath() {
|
|
5
|
+
return process.env.HD_CONFIG ?? join(homedir(), ".config/hd/config.json");
|
|
6
|
+
}
|
|
7
|
+
function normalizeWorkspace(value) {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
9
|
+
return null;
|
|
10
|
+
const row = value;
|
|
11
|
+
if (typeof row.url !== "string" || typeof row.api_key !== "string" || !row.url || !row.api_key) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return { url: row.url.replace(/\/$/, ""), api_key: row.api_key };
|
|
15
|
+
}
|
|
16
|
+
function missing(path) {
|
|
17
|
+
return new Error(`missing ${path}\nWrite { "current": "workspace", "workspaces": { "workspace": { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_..." } } }`);
|
|
18
|
+
}
|
|
19
|
+
export function loadStoredConfig(path = configPath()) {
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = readFileSync(path, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw missing(path);
|
|
26
|
+
}
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
// 0.4 and earlier stored one workspace at the top level. Rewrite it once so
|
|
29
|
+
// every later read sees the multi-workspace format.
|
|
30
|
+
const legacy = normalizeWorkspace(parsed);
|
|
31
|
+
if (legacy && typeof parsed.slug === "string" && parsed.slug) {
|
|
32
|
+
const migrated = { current: parsed.slug, workspaces: { [parsed.slug]: legacy } };
|
|
33
|
+
writeStoredConfig(migrated, path);
|
|
34
|
+
return migrated;
|
|
35
|
+
}
|
|
36
|
+
const current = typeof parsed.current === "string" ? parsed.current : "";
|
|
37
|
+
const source = parsed.workspaces;
|
|
38
|
+
if (!current || !source || typeof source !== "object" || Array.isArray(source)) {
|
|
39
|
+
throw new Error(`${path} needs current and workspaces`);
|
|
40
|
+
}
|
|
41
|
+
const workspaces = {};
|
|
42
|
+
for (const [slug, value] of Object.entries(source)) {
|
|
43
|
+
const workspace = normalizeWorkspace(value);
|
|
44
|
+
if (workspace)
|
|
45
|
+
workspaces[slug] = workspace;
|
|
46
|
+
}
|
|
47
|
+
if (!workspaces[current])
|
|
48
|
+
throw new Error(`${path} current workspace ${current} is not configured`);
|
|
49
|
+
return { current, workspaces };
|
|
50
|
+
}
|
|
51
|
+
export function loadConfig(slug) {
|
|
52
|
+
const stored = loadStoredConfig();
|
|
53
|
+
const selected = slug ?? stored.current;
|
|
54
|
+
const workspace = stored.workspaces[selected];
|
|
55
|
+
if (!workspace)
|
|
56
|
+
throw new Error(`No configured workspace ${selected}.`);
|
|
57
|
+
return { ...workspace, slug: selected };
|
|
58
|
+
}
|
|
59
|
+
export function writeStoredConfig(config, path = configPath()) {
|
|
60
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
61
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
62
|
+
chmodSync(path, 0o600);
|
|
63
|
+
}
|
|
64
|
+
/** Store credentials for a workspace and make it current. */
|
|
65
|
+
export function writeHdConfig(config, path = configPath()) {
|
|
66
|
+
const stored = existsSync(path)
|
|
67
|
+
? loadStoredConfig(path)
|
|
68
|
+
: { current: config.slug, workspaces: {} };
|
|
69
|
+
stored.workspaces[config.slug] = { url: config.url.replace(/\/$/, ""), api_key: config.api_key };
|
|
70
|
+
stored.current = config.slug;
|
|
71
|
+
writeStoredConfig(stored, path);
|
|
72
|
+
}
|
|
73
|
+
export function switchWorkspace(slug, path = configPath()) {
|
|
74
|
+
const stored = loadStoredConfig(path);
|
|
75
|
+
const workspace = stored.workspaces[slug];
|
|
76
|
+
if (!workspace)
|
|
77
|
+
throw new Error(`No configured workspace ${slug}.`);
|
|
78
|
+
writeStoredConfig({ ...stored, current: slug }, path);
|
|
79
|
+
return { ...workspace, slug };
|
|
80
|
+
}
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { writeHdConfig } from "./config.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
export const LAUNCHD_LABEL = "io.higherops.hdx-runner";
|
|
10
|
+
export const SYSTEMD_UNIT = "hdx-runner.service";
|
|
11
|
+
export function repoRootFrom(fileUrl = import.meta.url) {
|
|
12
|
+
return path.resolve(path.dirname(fileURLToPath(fileUrl)), "../../..");
|
|
13
|
+
}
|
|
14
|
+
export function parseEnvFile(text) {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const line of text.split(/\r?\n/)) {
|
|
17
|
+
if (!line || line.startsWith("#"))
|
|
18
|
+
continue;
|
|
19
|
+
const eq = line.indexOf("=");
|
|
20
|
+
if (eq <= 0)
|
|
21
|
+
continue;
|
|
22
|
+
out[line.slice(0, eq)] = line.slice(eq + 1);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
export function formatEnvFile(values) {
|
|
27
|
+
return `${Object.entries(values)
|
|
28
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
29
|
+
.join("\n")}\n`;
|
|
30
|
+
}
|
|
31
|
+
function xmlEscape(value) {
|
|
32
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
33
|
+
}
|
|
34
|
+
export function launchdPlist(opts) {
|
|
35
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
36
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
37
|
+
<plist version="1.0">
|
|
38
|
+
<dict>
|
|
39
|
+
<key>Label</key>
|
|
40
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
41
|
+
<key>ProgramArguments</key>
|
|
42
|
+
<array>
|
|
43
|
+
<string>/bin/bash</string>
|
|
44
|
+
<string>-lc</string>
|
|
45
|
+
<string>set -a; source "${xmlEscape(opts.envFile)}"; set +a; exec "${xmlEscape(opts.nodePath)}" --experimental-strip-types "${xmlEscape(opts.runnerPath)}"</string>
|
|
46
|
+
</array>
|
|
47
|
+
<key>WorkingDirectory</key>
|
|
48
|
+
<string>${xmlEscape(opts.workDir)}</string>
|
|
49
|
+
<key>RunAtLoad</key>
|
|
50
|
+
<true/>
|
|
51
|
+
<key>KeepAlive</key>
|
|
52
|
+
<true/>
|
|
53
|
+
<key>StandardOutPath</key>
|
|
54
|
+
<string>${xmlEscape(opts.logPath)}</string>
|
|
55
|
+
<key>StandardErrorPath</key>
|
|
56
|
+
<string>${xmlEscape(opts.logPath)}</string>
|
|
57
|
+
</dict>
|
|
58
|
+
</plist>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
export function systemdUnit(opts) {
|
|
62
|
+
return `[Unit]
|
|
63
|
+
Description=HDX runner
|
|
64
|
+
After=network-online.target
|
|
65
|
+
|
|
66
|
+
[Service]
|
|
67
|
+
Type=simple
|
|
68
|
+
EnvironmentFile=${opts.envFile}
|
|
69
|
+
WorkingDirectory=${opts.workDir}
|
|
70
|
+
ExecStart=${opts.nodePath} --experimental-strip-types ${opts.runnerPath}
|
|
71
|
+
Restart=always
|
|
72
|
+
RestartSec=5
|
|
73
|
+
|
|
74
|
+
[Install]
|
|
75
|
+
WantedBy=default.target
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
export function parseHostFlags(argv) {
|
|
79
|
+
const flags = {};
|
|
80
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
81
|
+
const arg = argv[i];
|
|
82
|
+
const next = () => {
|
|
83
|
+
const value = argv[i + 1];
|
|
84
|
+
if (!value || value.startsWith("--"))
|
|
85
|
+
throw new Error(`Missing value for ${arg}`);
|
|
86
|
+
i += 1;
|
|
87
|
+
return value;
|
|
88
|
+
};
|
|
89
|
+
if (arg === "--host")
|
|
90
|
+
flags.host = next();
|
|
91
|
+
else if (arg === "--env-out")
|
|
92
|
+
flags.envFile = next();
|
|
93
|
+
else if (arg === "--worktree-root")
|
|
94
|
+
flags.worktreeRoot = next();
|
|
95
|
+
else if (arg === "--install-launchd")
|
|
96
|
+
flags.installLaunchd = true;
|
|
97
|
+
else if (arg === "--install-systemd")
|
|
98
|
+
flags.installSystemd = true;
|
|
99
|
+
else if (arg === "--url")
|
|
100
|
+
flags.url = next();
|
|
101
|
+
else if (arg === "--api-key")
|
|
102
|
+
flags.apiKey = next();
|
|
103
|
+
else if (arg === "--slug")
|
|
104
|
+
flags.slug = next();
|
|
105
|
+
else if (arg === "--plist-out")
|
|
106
|
+
flags.plistOut = next();
|
|
107
|
+
else if (arg === "--unit-out")
|
|
108
|
+
flags.unitOut = next();
|
|
109
|
+
else
|
|
110
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
111
|
+
}
|
|
112
|
+
return flags;
|
|
113
|
+
}
|
|
114
|
+
export async function initHost(flags, env = process.env) {
|
|
115
|
+
const home = homedir();
|
|
116
|
+
const host = flags.host || env.RUNNER_HOST || hostname().replace(/\..*$/, "").toLowerCase();
|
|
117
|
+
const envFile = flags.envFile || path.join(home, ".config/hdx/runner.env");
|
|
118
|
+
const worktreeRoot = flags.worktreeRoot || env.WORKTREE_ROOT || path.join(home, "hdx-worktrees");
|
|
119
|
+
const repo = repoRootFrom();
|
|
120
|
+
const runnerPath = path.join(repo, "apps/runner/src/index.ts");
|
|
121
|
+
const nodePath = process.execPath;
|
|
122
|
+
let existing = {};
|
|
123
|
+
try {
|
|
124
|
+
existing = parseEnvFile(await readFile(envFile, "utf8"));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
existing = {};
|
|
128
|
+
}
|
|
129
|
+
const merged = {
|
|
130
|
+
...existing,
|
|
131
|
+
SUPABASE_URL: env.SUPABASE_URL || existing.SUPABASE_URL || "https://skssvedzleumwhofkqxv.supabase.co",
|
|
132
|
+
SUPABASE_SECRET_KEY: env.SUPABASE_SECRET_KEY || existing.SUPABASE_SECRET_KEY || "",
|
|
133
|
+
RUNNER_HOST: host,
|
|
134
|
+
WORKTREE_ROOT: worktreeRoot,
|
|
135
|
+
GH_TOKEN: env.GH_TOKEN || existing.GH_TOKEN || "",
|
|
136
|
+
};
|
|
137
|
+
await mkdir(path.dirname(envFile), { recursive: true });
|
|
138
|
+
await writeFile(envFile, formatEnvFile(merged), { encoding: "utf8", mode: 0o600 });
|
|
139
|
+
await chmod(envFile, 0o600);
|
|
140
|
+
if (flags.url && flags.apiKey && flags.slug) {
|
|
141
|
+
writeHdConfig({ url: flags.url.replace(/\/$/, ""), api_key: flags.apiKey, slug: flags.slug });
|
|
142
|
+
}
|
|
143
|
+
const lines = [`hd init host=${host}`, `env: ${envFile}`];
|
|
144
|
+
const workDir = repo;
|
|
145
|
+
if (process.platform === "darwin" || flags.plistOut || flags.installLaunchd) {
|
|
146
|
+
const plistPath = flags.plistOut || path.join(home, "Library/LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
147
|
+
const logPath = path.join(home, "Library/Logs/hdx-runner.log");
|
|
148
|
+
await mkdir(path.dirname(plistPath), { recursive: true });
|
|
149
|
+
await writeFile(plistPath, launchdPlist({ nodePath, runnerPath, envFile, logPath, workDir }), "utf8");
|
|
150
|
+
let loaded = false;
|
|
151
|
+
if (flags.installLaunchd && process.platform === "darwin") {
|
|
152
|
+
const uid = process.getuid?.() ?? 501;
|
|
153
|
+
const domain = `gui/${uid}`;
|
|
154
|
+
try {
|
|
155
|
+
await execFileAsync("launchctl", ["bootout", `${domain}/${LAUNCHD_LABEL}`]);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// First install has nothing to boot out.
|
|
159
|
+
}
|
|
160
|
+
await execFileAsync("launchctl", ["bootstrap", domain, plistPath]);
|
|
161
|
+
loaded = true;
|
|
162
|
+
}
|
|
163
|
+
lines.push(`launchd plist: ${plistPath}${loaded ? " (loaded)" : ""}`);
|
|
164
|
+
}
|
|
165
|
+
if (process.platform === "linux" || flags.unitOut || flags.installSystemd) {
|
|
166
|
+
const unitPath = flags.unitOut || path.join(home, ".config/systemd/user", SYSTEMD_UNIT);
|
|
167
|
+
await mkdir(path.dirname(unitPath), { recursive: true });
|
|
168
|
+
await writeFile(unitPath, systemdUnit({ nodePath, runnerPath, envFile, workDir }), "utf8");
|
|
169
|
+
let loaded = false;
|
|
170
|
+
if (flags.installSystemd && process.platform === "linux") {
|
|
171
|
+
await execFileAsync("systemctl", ["--user", "daemon-reload"]);
|
|
172
|
+
await execFileAsync("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
|
|
173
|
+
loaded = true;
|
|
174
|
+
}
|
|
175
|
+
lines.push(`systemd unit: ${unitPath}${loaded ? " (enabled)" : ""}`);
|
|
176
|
+
}
|
|
177
|
+
lines.push("Start the runner service to register this host.");
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|