@higherdev/cli 0.16.0 → 0.18.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
@@ -25,7 +25,7 @@ workspace-map config shapes are migrated automatically when they are read.
25
25
  | `hd --help` | Show the HigherDEV banner and usage |
26
26
  | `hd status` | Show workspace, ticket, run, and decision status |
27
27
  | `hd ticket list` | List tickets |
28
- | `hd ticket show KEY` | Show one ticket |
28
+ | `hd ticket show KEY [--json]` | Show one ticket (body, acceptance, timeline, messages, PR, runs, decisions) |
29
29
  | `hd ticket new [PATH.md]` | Create a ticket from the guided form or a Markdown spec |
30
30
  | `hd ticket new --title TITLE [--acceptance TEXT] [options]` | Create a ticket non-interactively |
31
31
  | `hd ticket queue KEY` | Queue a complete ticket now |
@@ -49,6 +49,7 @@ workspace-map config shapes are migrated automatically when they are read.
49
49
  | `hd env ls` | List workspace environment variable names |
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
+ | `hd host roll [--host HOST] [--address ADDR] [--checkout DIR]` | Pull, install, and restart a runner over SSH, then watch drain |
52
53
  | `hd host env ls` | List environment variable names on the workspace's default host |
53
54
  | `hd host env set NAME=VALUE [NAME=VALUE...]` | Set host-only environment variables |
54
55
  | `hd host env rm NAME` | Remove a host-only environment variable |
@@ -64,9 +65,15 @@ workspace-map config shapes are migrated automatically when they are read.
64
65
  | `hd upgrade --host HOST [--checkout DIR] [options]` | Refresh this host configuration |
65
66
 
66
67
  `hd init` uses `~/hdx` as its checkout by default. Pass `--checkout DIR` when the runner source is
67
- elsewhere. If the runner environment or service unit already exists, inspect the paths it prints and
68
+ elsewhere. Pass `--address HOST` to store `RUNNER_SSH_ADDRESS` so `hd host roll` can SSH to that
69
+ host. If the runner environment or service unit already exists, inspect the paths it prints and
68
70
  pass `--force` only when replacing that host configuration is intentional.
69
71
 
72
+ `hd host roll` SSHes to the host address (`--address`, or `RUNNER_SSH_ADDRESS` from the host
73
+ heartbeat), pulls the checkout, installs, and restarts the systemd unit. The runner drains live
74
+ runs for up to 15 minutes (`RUNNER_DRAIN_TIMEOUT_MS`) before force-closing leftovers. The systemd
75
+ unit `TimeoutStopSec` is one minute above that drain timeout.
76
+
70
77
  `hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
71
78
  branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` with push
72
79
  access for user-owned repositories or admin for organization-owned repositories unless `--runner-user USER`
package/dist/api.js CHANGED
@@ -58,14 +58,19 @@ export async function queueTicket(key, config = loadConfig()) {
58
58
  export async function cancelTicket(key, config = loadConfig()) {
59
59
  return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/cancel`);
60
60
  }
61
- export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
61
+ export async function listTicketRunEvents(key, afterAt, afterId, config = loadConfig()) {
62
62
  const query = new URLSearchParams();
63
63
  if (afterAt)
64
64
  query.set("after_at", afterAt);
65
65
  if (afterId)
66
66
  query.set("after_id", afterId);
67
- const suffix = query.size ? `?${query.toString()}` : "";
68
- return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events${suffix}`);
67
+ const suffix = query.size ? `&${query.toString()}` : "";
68
+ const result = await request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events?stream=1${suffix}`);
69
+ const runs = new Map((result.runs ?? []).map((run) => [run.id, run]));
70
+ return { ...result, events: result.events.map((event) => ({ ...event, run: runs.get(event.run_id) })) };
71
+ }
72
+ export async function listTicketStory(key, config = loadConfig()) {
73
+ return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events`);
69
74
  }
70
75
  export async function postMessage(fields, config = loadConfig()) {
71
76
  return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
package/dist/host.js CHANGED
@@ -53,6 +53,35 @@ export function launchdPlist(opts) {
53
53
  </plist>
54
54
  `;
55
55
  }
56
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 15 * 60_000;
57
+ export const HOST_ROLL_USAGE = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR]";
58
+ export function systemdStopTimeoutSec(drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS) {
59
+ return Math.ceil(drainTimeoutMs / 1_000) + 60;
60
+ }
61
+ export function formatDrainStatus(live, until, now = Date.now()) {
62
+ const remaining = Date.parse(until) - now;
63
+ const minutes = Number.isFinite(remaining) ? Math.max(0, Math.ceil(remaining / 60_000)) : 0;
64
+ const runs = live === 1 ? "1 run" : `${live} runs`;
65
+ return `draining: ${runs}, up to ${minutes}m`;
66
+ }
67
+ export function hostDrainFromStats(stats) {
68
+ if (!stats || typeof stats !== "object" || Array.isArray(stats))
69
+ return null;
70
+ const draining = stats.draining;
71
+ if (!draining || typeof draining !== "object" || Array.isArray(draining))
72
+ return null;
73
+ const live = Number(draining.live);
74
+ const until = draining.until;
75
+ if (!Number.isFinite(live) || live < 0 || typeof until !== "string")
76
+ return null;
77
+ return { live, until };
78
+ }
79
+ export function sshAddressFromStats(stats) {
80
+ if (!stats || typeof stats !== "object" || Array.isArray(stats))
81
+ return null;
82
+ const value = stats.ssh_address;
83
+ return typeof value === "string" && value.trim() ? value.trim() : null;
84
+ }
56
85
  export function systemdUnit(opts) {
57
86
  return `[Unit]
58
87
  Description=HDX runner
@@ -65,6 +94,7 @@ WorkingDirectory=${opts.workDir}
65
94
  ExecStart=${opts.nodePath} --experimental-strip-types ${opts.runnerPath}
66
95
  Restart=always
67
96
  RestartSec=5
97
+ TimeoutStopSec=${systemdStopTimeoutSec(opts.drainTimeoutMs)}
68
98
 
69
99
  [Install]
70
100
  WantedBy=default.target
@@ -85,6 +115,8 @@ export function parseHostFlags(argv) {
85
115
  flags.host = next();
86
116
  else if (arg === "--checkout")
87
117
  flags.checkout = next();
118
+ else if (arg === "--address")
119
+ flags.address = next();
88
120
  else if (arg === "--force")
89
121
  flags.force = true;
90
122
  else if (arg === "--env-out")
@@ -104,6 +136,45 @@ export function parseHostFlags(argv) {
104
136
  }
105
137
  return flags;
106
138
  }
139
+ export function parseHostRollFlags(argv) {
140
+ const flags = {};
141
+ for (let i = 0; i < argv.length; i += 1) {
142
+ const arg = argv[i];
143
+ const next = () => {
144
+ const value = argv[i + 1];
145
+ if (!value || value.startsWith("--"))
146
+ throw new Error(`Missing value for ${arg}`);
147
+ i += 1;
148
+ return value;
149
+ };
150
+ if (arg === "--host")
151
+ flags.host = next();
152
+ else if (arg === "--address")
153
+ flags.address = next();
154
+ else if (arg === "--checkout")
155
+ flags.checkout = next();
156
+ else if (!arg.startsWith("-") && !flags.host)
157
+ flags.host = arg;
158
+ else
159
+ throw new Error(arg.startsWith("-") ? `Unknown option: ${arg}` : HOST_ROLL_USAGE);
160
+ }
161
+ return flags;
162
+ }
163
+ function shellQuote(value) {
164
+ return `'${value.replace(/'/g, `'\\''`)}'`;
165
+ }
166
+ export function hostRollCommand(checkout) {
167
+ return [
168
+ "set -e",
169
+ `cd ${shellQuote(checkout)}`,
170
+ "git pull --ff-only",
171
+ "pnpm install",
172
+ "systemctl --user restart hdx-runner.service",
173
+ ].join(" && ");
174
+ }
175
+ async function defaultSsh(address, command) {
176
+ return execFileAsync("ssh", ["-o", "BatchMode=yes", address, "bash", "-lc", command], { encoding: "utf8" });
177
+ }
107
178
  async function exists(file) {
108
179
  try {
109
180
  await access(file);
@@ -159,6 +230,9 @@ export async function initHost(flags, env = process.env) {
159
230
  RUNNER_HOST: host,
160
231
  WORKTREE_ROOT: worktreeRoot,
161
232
  GH_TOKEN: env.GH_TOKEN || existing.GH_TOKEN || "",
233
+ ...(flags.address || env.RUNNER_SSH_ADDRESS || existing.RUNNER_SSH_ADDRESS
234
+ ? { RUNNER_SSH_ADDRESS: flags.address || env.RUNNER_SSH_ADDRESS || existing.RUNNER_SSH_ADDRESS }
235
+ : {}),
162
236
  };
163
237
  await mkdir(path.dirname(envFile), { recursive: true });
164
238
  await writeFile(envFile, formatEnvFile(merged), { encoding: "utf8", mode: 0o600 });
@@ -198,3 +272,48 @@ export async function initHost(flags, env = process.env) {
198
272
  lines.push("Start the runner service to register this host.");
199
273
  return lines.join("\n");
200
274
  }
275
+ const ROLL_WAIT_MS = DEFAULT_DRAIN_TIMEOUT_MS + 2 * 60_000;
276
+ export async function hostRoll(flags, deps = {}) {
277
+ const log = deps.log ?? ((line) => console.log(line));
278
+ const now = deps.now ?? Date.now;
279
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
280
+ const ssh = deps.ssh ?? defaultSsh;
281
+ const loadStatus = deps.getStatus ?? (await import("./api.js")).getStatus;
282
+ const snapshot = await loadStatus();
283
+ const host = flags.host ?? snapshot.workspace.default_host;
284
+ const address = flags.address ?? snapshot.host?.ssh_address ?? null;
285
+ if (!address) {
286
+ throw new Error(`hd host roll needs an SSH address for ${host}. Pass --address or set RUNNER_SSH_ADDRESS on the host.`);
287
+ }
288
+ const checkout = expandHome(flags.checkout ?? "~/hdx", homedir());
289
+ const command = hostRollCommand(checkout);
290
+ const lines = [`rolling ${host} at ${address}`];
291
+ log(lines[0]);
292
+ await ssh(address, command);
293
+ const restarted = `restarted ${host}; waiting for drain`;
294
+ lines.push(restarted);
295
+ log(restarted);
296
+ const before = snapshot.host?.last_seen_at ?? null;
297
+ const deadline = now() + (deps.waitMs ?? ROLL_WAIT_MS);
298
+ let lastDrain = "";
299
+ while (now() < deadline) {
300
+ const status = await loadStatus();
301
+ const drain = status.host?.draining ?? null;
302
+ if (drain) {
303
+ const line = formatDrainStatus(drain.live, drain.until, now());
304
+ if (line !== lastDrain) {
305
+ lastDrain = line;
306
+ lines.push(line);
307
+ log(line);
308
+ }
309
+ }
310
+ else if (status.host?.last_seen_at && status.host.last_seen_at !== before && status.host.runner_version) {
311
+ const line = `${host} runner ${status.host.runner_version}`;
312
+ lines.push(line);
313
+ log(line);
314
+ return lines;
315
+ }
316
+ await sleep(2_000);
317
+ }
318
+ throw new Error(`timed out waiting for ${host} to finish draining`);
319
+ }
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
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, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
5
- import { initHost, parseHostFlags } from "./host.js";
4
+ import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
5
+ import { formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
6
6
  import { login, parseLoginFlags } from "./login.js";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { epicProgressRows, readEpicSpec } from "./epics.js";
9
9
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
10
10
  import { ticketNew } from "./ticket-commands.js";
11
+ import { ticketViewLines } from "./tui/ticket-view.js";
11
12
  import { agentAdd, AGENT_ADD_USAGE } from "./agent-commands.js";
12
13
  import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
13
14
  import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
@@ -54,13 +55,17 @@ function printTickets(tickets) {
54
55
  }
55
56
  console.log(table(["KEY", "STATUS", "PROVIDER", "TITLE", "WHY"], tickets.map((ticket) => [
56
57
  c.bold(ticket.key), statusChip(ticket.status), ticket.provider ?? c.dim("-"),
57
- truncate(ticket.title, 48), ticket.stuck_reason ? c.yellow(truncate(ticket.stuck_reason, 40)) : "",
58
+ truncate(ticket.title, 48), ticket.latest_headline ? c.yellow(truncate(ticket.latest_headline, 40))
59
+ : ticket.stuck_reason ? c.yellow(truncate(ticket.stuck_reason, 40)) : "",
58
60
  ])));
59
61
  }
60
62
  async function cmdStatus() {
61
63
  const data = await getStatus();
62
64
  const state = data.workspace.paused ? c.yellow("off") : c.green("on");
63
65
  console.log(`${c.bold(data.workspace.name)} ${c.dim(data.workspace.repo)} ${state}\n`);
66
+ if (data.host?.draining) {
67
+ console.log(`${c.yellow(formatDrainStatus(data.host.draining.live, data.host.draining.until))}\n`);
68
+ }
64
69
  const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
65
70
  ?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
66
71
  .map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
@@ -85,23 +90,31 @@ async function cmdTicket(argv, deps = {}) {
85
90
  return;
86
91
  }
87
92
  if (action === "show") {
88
- const key = rest[0];
89
- if (!key)
90
- fail("usage: hd ticket show KEY");
91
- const { ticket } = await showTicket(key);
92
- console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)} ${ticket.title}`);
93
- if (ticket.stuck_reason)
94
- console.log(`stuck: ${ticket.stuck_reason}`);
95
- if (ticket.provider)
96
- console.log(`provider: ${ticket.provider}`);
97
- if (ticket.area)
98
- console.log(`area: ${ticket.area}`);
99
- if (ticket.pr_url)
100
- console.log(`pr: ${ticket.pr_url}`);
101
- if (ticket.branch)
102
- console.log(`branch: ${ticket.branch}`);
103
- if (ticket.body_md)
104
- console.log(`\n${ticket.body_md}`);
93
+ const parsed = flags(rest);
94
+ const key = parsed.rest[0];
95
+ if (!key || parsed.rest.length !== 1)
96
+ fail("usage: hd ticket show KEY [--json]");
97
+ const data = await showTicket(key.toUpperCase());
98
+ if (parsed.bools.has("json")) {
99
+ console.log(JSON.stringify(data));
100
+ return;
101
+ }
102
+ const { ticket, pr, events, runs, messages, decisions } = data;
103
+ const width = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
104
+ for (const line of ticketViewLines({
105
+ ...ticket,
106
+ stuck: ticket.stuck_reason,
107
+ pr,
108
+ timeline: events,
109
+ messages,
110
+ runs: runs.map((run) => ({
111
+ id: run.id, kind: run.kind, provider: run.provider, status: run.status,
112
+ summary: run.summary, elapsed_ms: run.elapsed_ms,
113
+ })),
114
+ decisions,
115
+ }, width)) {
116
+ console.log(line.text);
117
+ }
105
118
  return;
106
119
  }
107
120
  if (action === "new") {
@@ -182,7 +195,7 @@ async function cmdLogs(argv) {
182
195
  let afterId;
183
196
  const follow = bools.has("follow");
184
197
  async function tick() {
185
- const { events } = await listTicketEvents(key, afterAt, afterId);
198
+ const { events } = await listTicketRunEvents(key, afterAt, afterId);
186
199
  let printed = false;
187
200
  for (const event of events) {
188
201
  if (seen.has(event.id))
@@ -413,12 +426,23 @@ function githubSecretMissing(error) {
413
426
  return /404|not found|does not exist/i.test(message);
414
427
  }
415
428
  export async function cmdEnv(argv, deps = {}) {
416
- const [action, ...args] = argv;
417
- if (action === "ls") {
418
- const { env } = await listWorkspaceEnv();
429
+ const parsed = flags(argv);
430
+ const [rawAction, ...args] = parsed.rest;
431
+ const action = !rawAction && parsed.bools.has("vercel") ? "ls" : rawAction;
432
+ if (action === "ls" && !args.length && !Object.keys(parsed.opts).length
433
+ && [...parsed.bools].every((name) => name === "vercel")) {
434
+ const showVercel = parsed.bools.has("vercel");
435
+ const [{ env }, detail] = await Promise.all([listWorkspaceEnv(), showVercel ? getWorkspace() : null]);
419
436
  if (!env.length)
420
437
  return console.log(c.dim("No workspace environment variables."));
421
- console.log(table(["NAME", "UPDATED"], env.map((row) => [row.name, row.updated_at])));
438
+ if (!showVercel)
439
+ console.log(table(["NAME", "UPDATED"], env.map((row) => [row.name, row.updated_at])));
440
+ else {
441
+ const production = new Set(detail?.workspace.settings?.vercel?.production_env ?? []);
442
+ console.log(table(["NAME", "UPDATED", "VERCEL"], env.map((row) => [
443
+ row.name, row.updated_at, production.has(row.name) ? "yes" : "no",
444
+ ])));
445
+ }
422
446
  return;
423
447
  }
424
448
  if (action === "set" && args.length) {
@@ -461,11 +485,15 @@ export async function cmdEnv(argv, deps = {}) {
461
485
  console.log(`synced removal of ${name} to ${repo}`);
462
486
  return;
463
487
  }
464
- fail("usage: hd env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME");
488
+ fail("usage: hd env ls [--vercel] | set NAME=VALUE [NAME=VALUE...] | rm NAME");
465
489
  }
466
- async function cmdHost(argv) {
490
+ async function cmdHost(argv, deps = {}) {
467
491
  const [scope, action, ...args] = argv;
468
- const usage = "usage: hd host env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
492
+ if (scope === "roll") {
493
+ await hostRoll(parseHostRollFlags([action, ...args].filter((value) => Boolean(value))), deps);
494
+ return;
495
+ }
496
+ const usage = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR] | env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
469
497
  if (scope !== "env")
470
498
  fail(usage);
471
499
  const host = (await getStatus()).workspace.default_host;
@@ -563,7 +591,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
563
591
  return;
564
592
  }
565
593
  if (cmd === "host") {
566
- await cmdHost(rest);
594
+ await cmdHost(rest, deps);
567
595
  return;
568
596
  }
569
597
  if (cmd === "pause" || cmd === "off") {
package/dist/out.js CHANGED
@@ -64,14 +64,14 @@ export function usage() {
64
64
  return [
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
- ` ${c.blue("hd ticket list | show | new [PATH] | queue | cancel")} ticket operations`,
67
+ ` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel")} ticket operations`,
68
68
  ` ${c.blue("hd epic new PATH | list | approve | rm")} epic operations`,
69
69
  ` ${c.blue("hd plan")} use /architect in the TUI`,
70
70
  ` ${c.blue("hd workspace ls | use | new | set | rotate-key | grant-runner-access")} workspace operations`,
71
71
  ` ${c.blue("hd agents [add | rm | set]")} manage agents`,
72
72
  ` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
73
- ` ${c.blue("hd env ls | set | rm")} workspace environment`,
74
- ` ${c.blue("hd host env ls | set | rm")} host environment`,
73
+ ` ${c.blue("hd env ls [--vercel] | set | rm")} workspace environment`,
74
+ ` ${c.blue("hd host roll | env ls | set | rm")} roll a host or edit host env`,
75
75
  ` ${c.blue("hd logs KEY [-f]")} run events`,
76
76
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
77
77
  ` ${c.blue("hd inbox [--all] [--limit N] [--json]")} inbox messages`,