@higherdev/cli 0.15.0 → 0.15.3
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 +21 -6
- package/dist/index.js +20 -17
- package/dist/out.js +2 -2
- package/dist/ticket-commands.js +149 -0
- package/dist/tui/App.js +79 -11
- package/dist/tui/Help.js +6 -4
- package/dist/tui/data.js +19 -2
- package/dist/tui/parse.js +18 -3
- package/dist/workspace-commands.js +29 -2
- package/dist/workspace-preflight.js +23 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,8 @@ workspace-map config shapes are migrated automatically when they are read.
|
|
|
26
26
|
| `hd status` | Show workspace, ticket, run, and decision status |
|
|
27
27
|
| `hd ticket list` | List tickets |
|
|
28
28
|
| `hd ticket show KEY` | Show one ticket |
|
|
29
|
-
| `hd ticket new
|
|
29
|
+
| `hd ticket new [PATH.md]` | Create a ticket from the guided form or a Markdown spec |
|
|
30
|
+
| `hd ticket new --title TITLE [--acceptance TEXT] [options]` | Create a ticket non-interactively |
|
|
30
31
|
| `hd ticket queue KEY` | Queue a complete ticket now |
|
|
31
32
|
| `hd ticket cancel KEY` | Cancel a ticket |
|
|
32
33
|
| `hd epic new PATH [--title TITLE]` | Create an epic from a Markdown spec |
|
|
@@ -39,6 +40,7 @@ workspace-map config shapes are migrated automatically when they are read.
|
|
|
39
40
|
| `hd workspace new --name NAME --repo OWNER/NAME [options]` | Preflight GitHub, wire the runner, and create a paused workspace |
|
|
40
41
|
| `hd workspace set [options]` | Update settings; max turns uses `--max-turns KIND=N[,KIND=N...]` |
|
|
41
42
|
| `hd workspace rotate-key` | Rotate the shared API key and save it locally |
|
|
43
|
+
| `hd workspace grant-runner-access [--runner-user USER]` | Grant and confirm write-or-better access for the workspace runner |
|
|
42
44
|
| `hd agents` | List agents |
|
|
43
45
|
| `hd agents add ROLE --provider P --model M [options]` | Add an agent |
|
|
44
46
|
| `hd agents rm ROLE\|ID` | Remove an unambiguous agent |
|
|
@@ -65,11 +67,24 @@ elsewhere. If the runner environment or service unit already exists, inspect the
|
|
|
65
67
|
pass `--force` only when replacing that host configuration is intentional.
|
|
66
68
|
|
|
67
69
|
`hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
|
|
68
|
-
branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus`
|
|
69
|
-
|
|
70
|
+
branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` with push
|
|
71
|
+
access for user-owned repositories or admin for organization-owned repositories unless `--runner-user USER`
|
|
72
|
+
overrides it. On a terminal, missing name or repo flags start a guided wizard. If
|
|
70
73
|
the repository is missing, approve private creation interactively, use `--create` to force it, or
|
|
71
74
|
`--no-create` to fail. Fully flagged calls remain non-interactive for scripts and Mel.
|
|
72
75
|
|
|
76
|
+
On a terminal, `hd ticket new` opens a guided form. It defaults the area from the newest ticket and
|
|
77
|
+
offers the enabled builder providers. Body and acceptance accept Markdown lines until a blank line,
|
|
78
|
+
then the form confirms creation and optionally queues the new ticket. `hd ticket new PATH.md` reads
|
|
79
|
+
the first `#` heading as the title, optional `area` and `provider` front matter, and a
|
|
80
|
+
`## Acceptance` section as the acceptance criteria. Fully flagged ticket creation skips all prompts.
|
|
81
|
+
|
|
82
|
+
For workspaces created before runner access was wired automatically, run
|
|
83
|
+
`hd workspace grant-runner-access`. It uses the operator's authenticated `gh` account, defaults to
|
|
84
|
+
`mel-ilotus`, grants push on user-owned repositories or admin on organization-owned repositories, and
|
|
85
|
+
confirms write-or-better access through GitHub's collaborator permission endpoint. The old
|
|
86
|
+
`grant-runner-admin` name remains an alias.
|
|
87
|
+
|
|
73
88
|
`hd env set` and `hd env rm` also update GitHub Actions secrets on the current workspace repository.
|
|
74
89
|
They require the operator's authenticated `gh` account to have repository admin permission. Secret values are sent
|
|
75
90
|
to `gh` through stdin and are never printed.
|
|
@@ -77,8 +92,8 @@ to `gh` through stdin and are never printed.
|
|
|
77
92
|
Use `hd host env set SUPABASE_ACCESS_TOKEN=... SUPABASE_ORG_ID=...` once to let the runner provision
|
|
78
93
|
Supabase for workspaces assigned to that host. Host values are never passed to builder processes.
|
|
79
94
|
|
|
80
|
-
Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/cancel`, `/epic new`,
|
|
81
|
-
`/epic approve`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
|
|
82
|
-
`/workspace new`, `/workspace set`, `/workspace rotate-key`, `/feed`,
|
|
95
|
+
Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/ticket new [PATH.md]`, `/queue`, `/cancel`, `/msg`, `/logs`, `/epic new`,
|
|
96
|
+
`/epic approve`, `/epic rm`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
|
|
97
|
+
`/workspace new`, `/workspace set`, `/workspace rotate-key`, `/workspace grant-runner-access`, `/feed`,
|
|
83
98
|
`/on`, `/off`, `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
|
|
84
99
|
the HDX API every five seconds.
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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, createAgent, createEpic,
|
|
4
|
+
import { approveEpic, answerDecision, cancelTicket, createAgent, createEpic, deleteAgent, deleteEpic, getStatus, listAgents, listEpics, listHostEnv, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
|
|
5
5
|
import { initHost, parseHostFlags } 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
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { ticketNew } from "./ticket-commands.js";
|
|
11
|
+
import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
|
|
12
|
+
import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
|
|
12
13
|
function fail(message) {
|
|
13
14
|
console.error(message);
|
|
14
15
|
process.exit(1);
|
|
@@ -70,7 +71,7 @@ async function cmdStatus() {
|
|
|
70
71
|
console.log(`\n${c.yellow(String(data.decisions.length))} open decision${data.decisions.length === 1 ? "" : "s"}`);
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
|
-
async function cmdTicket(argv) {
|
|
74
|
+
async function cmdTicket(argv, deps = {}) {
|
|
74
75
|
const [action, ...rest] = argv;
|
|
75
76
|
if (action === "list") {
|
|
76
77
|
const data = await listTickets();
|
|
@@ -98,18 +99,8 @@ async function cmdTicket(argv) {
|
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
100
101
|
if (action === "new") {
|
|
101
|
-
const {
|
|
102
|
-
|
|
103
|
-
fail("usage: hd ticket new --title TITLE [--body TEXT] [--acceptance TEXT] [--area TAG] [--provider NAME] [--epic ID]");
|
|
104
|
-
const { ticket } = await createTicket({
|
|
105
|
-
title: opts.title,
|
|
106
|
-
body_md: opts.body,
|
|
107
|
-
acceptance_md: opts.acceptance,
|
|
108
|
-
area: opts.area,
|
|
109
|
-
provider: opts.provider,
|
|
110
|
-
epic_id: opts.epic,
|
|
111
|
-
});
|
|
112
|
-
console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)} ${ticket.title}`);
|
|
102
|
+
const { ticket, queued } = await ticketNew(rest, deps);
|
|
103
|
+
console.log(`${c.bold(ticket.key)} ${statusChip(queued ? "queued" : ticket.status)} ${ticket.title}`);
|
|
113
104
|
return;
|
|
114
105
|
}
|
|
115
106
|
if (action === "queue") {
|
|
@@ -258,6 +249,11 @@ async function cmdWorkspace(argv, deps = {}) {
|
|
|
258
249
|
console.log(`${workspace.slug} ${workspace.name} ${workspace.repo}`);
|
|
259
250
|
return;
|
|
260
251
|
}
|
|
252
|
+
if (action === "grant-runner-access" || action === "grant-runner-admin") {
|
|
253
|
+
const result = await workspaceGrantRunnerAccess(rest, deps);
|
|
254
|
+
console.log(`${result.runnerUser} ${result.permission} ${result.repo}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
261
257
|
if (action === "rotate-key") {
|
|
262
258
|
if (rest.length)
|
|
263
259
|
fail(WORKSPACE_USAGE);
|
|
@@ -269,6 +265,13 @@ async function cmdWorkspace(argv, deps = {}) {
|
|
|
269
265
|
if (action === "set") {
|
|
270
266
|
const workspace = await workspaceSet(rest);
|
|
271
267
|
console.log(`${workspace.slug} ${workspace.name} ${workspace.repo}`);
|
|
268
|
+
try {
|
|
269
|
+
const permission = await runnerRepoPermission(workspace.repo, HDX_RUNNER_GH_USER, deps.gh ?? defaultGh);
|
|
270
|
+
if (!hasWriteRepoPermission(permission)) {
|
|
271
|
+
console.log(`hint: runner ${HDX_RUNNER_GH_USER} has ${permission} permission; run hd workspace grant-runner-access`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch { }
|
|
272
275
|
return;
|
|
273
276
|
}
|
|
274
277
|
if (action !== "new")
|
|
@@ -468,7 +471,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
468
471
|
return;
|
|
469
472
|
}
|
|
470
473
|
if (cmd === "ticket") {
|
|
471
|
-
await cmdTicket(rest);
|
|
474
|
+
await cmdTicket(rest, deps);
|
|
472
475
|
return;
|
|
473
476
|
}
|
|
474
477
|
if (cmd === "epic") {
|
package/dist/out.js
CHANGED
|
@@ -64,10 +64,10 @@ 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 | queue | cancel")} ticket operations`,
|
|
67
|
+
` ${c.blue("hd ticket list | show | 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
|
-
` ${c.blue("hd workspace ls | use | new | set | rotate-key")} workspace operations`,
|
|
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
73
|
` ${c.blue("hd env ls | set | rm")} workspace environment`,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createTicket, listAgents, listTickets, queueTicket } from "./api.js";
|
|
3
|
+
import { promptOnStdin } from "./prompt.js";
|
|
4
|
+
export const TICKET_NEW_USAGE = "usage: hd ticket new [PATH.md] | --title TITLE [--body TEXT] [--acceptance TEXT] [--area TAG] [--provider NAME] [--epic ID]";
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const opts = {};
|
|
7
|
+
const bools = new Set();
|
|
8
|
+
const rest = [];
|
|
9
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (!arg.startsWith("--")) {
|
|
12
|
+
rest.push(arg);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const next = argv[i + 1];
|
|
16
|
+
if (next && !next.startsWith("--")) {
|
|
17
|
+
opts[arg.slice(2)] = next;
|
|
18
|
+
i += 1;
|
|
19
|
+
}
|
|
20
|
+
else
|
|
21
|
+
bools.add(arg.slice(2));
|
|
22
|
+
}
|
|
23
|
+
return { opts, bools, rest };
|
|
24
|
+
}
|
|
25
|
+
function metadata(lines) {
|
|
26
|
+
const copy = [...lines];
|
|
27
|
+
const values = {};
|
|
28
|
+
let indexes = [];
|
|
29
|
+
if (copy[0]?.trim() === "---") {
|
|
30
|
+
const end = copy.slice(1).findIndex((line) => line.trim() === "---");
|
|
31
|
+
if (end < 0)
|
|
32
|
+
throw new Error("Ticket spec front matter is not closed.");
|
|
33
|
+
indexes = Array.from({ length: end + 2 }, (_, index) => index);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
let at = 0;
|
|
37
|
+
while (copy[at]?.trim() === "")
|
|
38
|
+
at += 1;
|
|
39
|
+
while (/^(area|provider)\s*:/i.test(copy[at] ?? "")) {
|
|
40
|
+
indexes.push(at);
|
|
41
|
+
at += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const index of indexes) {
|
|
45
|
+
const match = copy[index].match(/^(area|provider)\s*:\s*(.+?)\s*$/i);
|
|
46
|
+
if (match)
|
|
47
|
+
values[match[1].toLowerCase()] = match[2];
|
|
48
|
+
copy[index] = "";
|
|
49
|
+
}
|
|
50
|
+
return { ...values, lines: copy };
|
|
51
|
+
}
|
|
52
|
+
export function parseTicketSpec(markdown) {
|
|
53
|
+
const parsed = metadata(markdown.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n").split("\n"));
|
|
54
|
+
const titleAt = parsed.lines.findIndex((line) => /^#\s+\S/.test(line));
|
|
55
|
+
if (titleAt < 0)
|
|
56
|
+
throw new Error("Ticket spec needs a # title heading.");
|
|
57
|
+
const title = parsed.lines[titleAt].replace(/^#\s+/, "").trim();
|
|
58
|
+
const acceptanceAt = parsed.lines.findIndex((line) => /^##\s+Acceptance\s*$/i.test(line.trim()));
|
|
59
|
+
let acceptanceEnd = parsed.lines.length;
|
|
60
|
+
if (acceptanceAt >= 0) {
|
|
61
|
+
const nextHeading = parsed.lines.slice(acceptanceAt + 1)
|
|
62
|
+
.findIndex((line) => /^#{1,2}\s+/.test(line));
|
|
63
|
+
if (nextHeading >= 0)
|
|
64
|
+
acceptanceEnd = acceptanceAt + 1 + nextHeading;
|
|
65
|
+
}
|
|
66
|
+
const body = parsed.lines.filter((_, index) => index !== titleAt
|
|
67
|
+
&& index !== acceptanceAt && !(acceptanceAt >= 0 && index > acceptanceAt && index < acceptanceEnd));
|
|
68
|
+
const acceptance = acceptanceAt < 0 ? [] : parsed.lines.slice(acceptanceAt + 1, acceptanceEnd);
|
|
69
|
+
return {
|
|
70
|
+
title,
|
|
71
|
+
body_md: body.join("\n").trim(),
|
|
72
|
+
acceptance_md: acceptance.join("\n").trim(),
|
|
73
|
+
...(parsed.area ? { area: parsed.area } : {}),
|
|
74
|
+
...(parsed.provider ? { provider: parsed.provider } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function readTicketSpec(path) {
|
|
78
|
+
return parseTicketSpec(await readFile(path, "utf8"));
|
|
79
|
+
}
|
|
80
|
+
const yes = (answer) => !["n", "no"].includes(answer.trim().toLowerCase());
|
|
81
|
+
const queueNow = (answer) => ["y", "yes"].includes(answer.trim().toLowerCase());
|
|
82
|
+
async function lines(prompt, label) {
|
|
83
|
+
const result = [];
|
|
84
|
+
let question = `${label} (Markdown is fine; enter a blank line to finish):\n> `;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const line = await prompt(question);
|
|
87
|
+
if (!line)
|
|
88
|
+
return result.join("\n").trim();
|
|
89
|
+
result.push(line);
|
|
90
|
+
question = "> ";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function guided(deps, config) {
|
|
94
|
+
const prompt = deps.prompt ?? promptOnStdin;
|
|
95
|
+
const [{ tickets }, { agents }] = await Promise.all([
|
|
96
|
+
(deps.listTickets ?? listTickets)(config), (deps.listAgents ?? listAgents)(config),
|
|
97
|
+
]);
|
|
98
|
+
const title = (await prompt("Title: ")).trim();
|
|
99
|
+
if (!title)
|
|
100
|
+
throw new Error("Title is required.");
|
|
101
|
+
const last = [...tickets].sort((a, b) => a.created_at.localeCompare(b.created_at)).at(-1);
|
|
102
|
+
const defaultArea = last?.area ?? "";
|
|
103
|
+
const area = (await prompt(`Area${defaultArea ? ` [${defaultArea}]` : ""}: `)).trim() || defaultArea;
|
|
104
|
+
const providers = [...new Set(agents.filter((agent) => agent.enabled && agent.role === "builder")
|
|
105
|
+
.map((agent) => agent.provider))];
|
|
106
|
+
if (!providers.length)
|
|
107
|
+
throw new Error("No enabled builder providers are available.");
|
|
108
|
+
const choice = (await prompt(`Provider (${providers.map((provider, index) => `${index + 1}=${provider}`).join(", ")}) [${providers[0]}]: `)).trim();
|
|
109
|
+
const provider = /^\d+$/.test(choice) ? providers[Number(choice) - 1] : choice || providers[0];
|
|
110
|
+
if (!provider || !providers.includes(provider))
|
|
111
|
+
throw new Error(`Choose a provider from: ${providers.join(", ")}.`);
|
|
112
|
+
return { title, area, provider, body_md: await lines(prompt, "Body"),
|
|
113
|
+
acceptance_md: await lines(prompt, "Acceptance") };
|
|
114
|
+
}
|
|
115
|
+
export async function ticketNew(argv, deps = {}) {
|
|
116
|
+
const { opts, bools, rest } = parseArgs(argv);
|
|
117
|
+
const hasFlags = Object.keys(opts).length > 0 || bools.size > 0;
|
|
118
|
+
const interactive = deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
119
|
+
let fields;
|
|
120
|
+
let offerQueue = false;
|
|
121
|
+
if (hasFlags) {
|
|
122
|
+
if (rest.length || bools.size || !opts.title)
|
|
123
|
+
throw new Error(TICKET_NEW_USAGE);
|
|
124
|
+
fields = { title: opts.title, body_md: opts.body, acceptance_md: opts.acceptance,
|
|
125
|
+
area: opts.area, provider: opts.provider, epic_id: opts.epic };
|
|
126
|
+
}
|
|
127
|
+
else if (rest.length === 1) {
|
|
128
|
+
fields = await readTicketSpec(rest[0]);
|
|
129
|
+
offerQueue = interactive;
|
|
130
|
+
}
|
|
131
|
+
else if (rest.length === 0 && interactive) {
|
|
132
|
+
fields = await guided(deps, deps.config);
|
|
133
|
+
const summary = `${fields.title} | area ${fields.area || "-"} | provider ${fields.provider || "-"}`;
|
|
134
|
+
if (!yes(await (deps.prompt ?? promptOnStdin)(`${summary}\nCreate? [Y/n] `))) {
|
|
135
|
+
throw new Error("Ticket creation cancelled.");
|
|
136
|
+
}
|
|
137
|
+
offerQueue = true;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
throw new Error(TICKET_NEW_USAGE);
|
|
141
|
+
}
|
|
142
|
+
const created = await (deps.createTicket ?? createTicket)(fields, deps.config);
|
|
143
|
+
let queued = false;
|
|
144
|
+
if (offerQueue && queueNow(await (deps.prompt ?? promptOnStdin)("Queue it now? [y/N] "))) {
|
|
145
|
+
await (deps.queueTicket ?? queueTicket)(created.ticket.key, deps.config);
|
|
146
|
+
queued = true;
|
|
147
|
+
}
|
|
148
|
+
return { ticket: created.ticket, queued };
|
|
149
|
+
}
|
package/dist/tui/App.js
CHANGED
|
@@ -4,7 +4,8 @@ import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { epicProgressRows } from "../epics.js";
|
|
6
6
|
import { promptOnStdin } from "../prompt.js";
|
|
7
|
-
import {
|
|
7
|
+
import { ticketNew } from "../ticket-commands.js";
|
|
8
|
+
import { workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
|
|
8
9
|
import { Banner } from "./Banner.js";
|
|
9
10
|
import { Bubble } from "./Bubble.js";
|
|
10
11
|
import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
|
|
@@ -18,7 +19,7 @@ import { alertOnce } from "./alert.js";
|
|
|
18
19
|
import { bubbleRows } from "./height.js";
|
|
19
20
|
import { planLayout, splitPanels } from "./layout.js";
|
|
20
21
|
import { parseLine } from "./parse.js";
|
|
21
|
-
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, queueTicket, resolveDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, waitForReply, } from "./data.js";
|
|
22
|
+
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, waitForReply, } from "./data.js";
|
|
22
23
|
import { inputActive, promptPlaceholder, QUEUED_STEP, REPLY_WAIT_MS, settleChatReply } from "./chat-wait.js";
|
|
23
24
|
import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
|
|
24
25
|
import { appendLines, runLabels, toStreamLines } from "./stream.js";
|
|
@@ -47,6 +48,7 @@ export function App({ initial }) {
|
|
|
47
48
|
const [ticketKey, setTicketKey] = useState(null);
|
|
48
49
|
const [ready, setReady] = useState(false);
|
|
49
50
|
const [stream, setStream] = useState([]);
|
|
51
|
+
const [logsFilter, setLogsFilter] = useState(null);
|
|
50
52
|
const [cursor, setCursor] = useState(null);
|
|
51
53
|
const [started, setStarted] = useState(false);
|
|
52
54
|
const [field, setField] = useState(null);
|
|
@@ -99,19 +101,26 @@ export function App({ initial }) {
|
|
|
99
101
|
const labels = useMemo(() => runLabels(board), [board]);
|
|
100
102
|
const liveRunIds = [...labels.keys()].sort().join(",");
|
|
101
103
|
useEffect(() => {
|
|
102
|
-
if (!liveRunIds) {
|
|
104
|
+
if (!liveRunIds && !logsFilter) {
|
|
103
105
|
setStream([]);
|
|
104
106
|
return;
|
|
105
107
|
}
|
|
106
108
|
const token = loads.current.start(workspace.id);
|
|
107
|
-
void loadLiveEvents(config, board)
|
|
109
|
+
void loadLiveEvents(config, board, logsFilter)
|
|
108
110
|
.then((events) => {
|
|
109
111
|
if (!loads.current.isCurrent(token))
|
|
110
112
|
return;
|
|
111
|
-
|
|
113
|
+
const eventLabels = new Map(labels);
|
|
114
|
+
if (logsFilter)
|
|
115
|
+
for (const event of events)
|
|
116
|
+
if (!eventLabels.has(event.run_id)) {
|
|
117
|
+
eventLabels.set(event.run_id, { runId: event.run_id, agent: logsFilter, ticket: logsFilter });
|
|
118
|
+
}
|
|
119
|
+
setStream((prior) => appendLines(prior, toStreamLines(events, eventLabels)));
|
|
112
120
|
})
|
|
113
121
|
.catch(() => { });
|
|
114
|
-
}, [liveRunIds, board, config, labels, workspace.id]);
|
|
122
|
+
}, [liveRunIds, board, config, labels, workspace.id, logsFilter]);
|
|
123
|
+
useEffect(() => setStream([]), [logsFilter]);
|
|
115
124
|
const say = useCallback((speaker, body, steps) => {
|
|
116
125
|
setMessages((prior) => [...prior, { id: nextId(), speaker, body, steps, done: true }]);
|
|
117
126
|
}, []);
|
|
@@ -201,6 +210,7 @@ export function App({ initial }) {
|
|
|
201
210
|
setBoard(snapshot.board);
|
|
202
211
|
setFeed(snapshot.feed);
|
|
203
212
|
setStream([]);
|
|
213
|
+
setLogsFilter(null);
|
|
204
214
|
acknowledgedMessages.current.clear();
|
|
205
215
|
setCursor(null);
|
|
206
216
|
selectedRef.current = null;
|
|
@@ -341,11 +351,37 @@ export function App({ initial }) {
|
|
|
341
351
|
say("system", `Updated ${updated.slug}: ${updated.name} (${updated.repo}).`);
|
|
342
352
|
await refresh();
|
|
343
353
|
}
|
|
344
|
-
else {
|
|
354
|
+
else if (action.command === "rotate-key") {
|
|
345
355
|
const rotated = await workspaceRotateKey();
|
|
346
356
|
setConfig(loadConfig());
|
|
347
357
|
say("system", `API key: ${rotated.apiKey}\nSaved locally. Run hd login with the new key on other machines.`);
|
|
348
358
|
}
|
|
359
|
+
else {
|
|
360
|
+
let granted;
|
|
361
|
+
await suspendTerminal(async () => { granted = await workspaceGrantRunnerAccess(action.args); });
|
|
362
|
+
if (!granted)
|
|
363
|
+
throw new Error("Runner access command did not finish.");
|
|
364
|
+
say("system", `${granted.runnerUser} has ${granted.permission} access to ${granted.repo}.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
setBusy(false);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
case "ticket-new":
|
|
375
|
+
setBusy(true);
|
|
376
|
+
try {
|
|
377
|
+
let result;
|
|
378
|
+
await suspendTerminal(async () => {
|
|
379
|
+
result = await ticketNew(action.args, { config, isTTY: true, prompt: tuiPrompt });
|
|
380
|
+
});
|
|
381
|
+
if (!result)
|
|
382
|
+
throw new Error("Ticket creation did not finish.");
|
|
383
|
+
say("system", `Created ${result.ticket.key}: ${result.ticket.title}${result.queued ? " and queued it" : ""}.`);
|
|
384
|
+
await refresh();
|
|
349
385
|
}
|
|
350
386
|
catch (error) {
|
|
351
387
|
setNotice(error instanceof Error ? error.message : String(error));
|
|
@@ -400,6 +436,20 @@ export function App({ initial }) {
|
|
|
400
436
|
setBusy(false);
|
|
401
437
|
}
|
|
402
438
|
return;
|
|
439
|
+
case "epic-rm":
|
|
440
|
+
setBusy(true);
|
|
441
|
+
try {
|
|
442
|
+
await deleteEpic(config, action.id);
|
|
443
|
+
say("system", `Removed draft epic ${action.id}.`);
|
|
444
|
+
await refresh();
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
setBusy(false);
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
403
453
|
case "epics": {
|
|
404
454
|
const rows = epicProgressRows(board.epics, board.tickets);
|
|
405
455
|
say("system", rows.length
|
|
@@ -497,10 +547,28 @@ export function App({ initial }) {
|
|
|
497
547
|
setBusy(false);
|
|
498
548
|
}
|
|
499
549
|
return;
|
|
550
|
+
case "message":
|
|
551
|
+
setBusy(true);
|
|
552
|
+
try {
|
|
553
|
+
await postTicketMessage(config, action.key, action.text);
|
|
554
|
+
say("system", `Sent a builder message on ${action.key}.`);
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
setBusy(false);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
case "logs":
|
|
564
|
+
setLogsFilter(action.key);
|
|
565
|
+
say("system", action.key ? `Activity filtered to ${action.key}.` : "Activity filter cleared.");
|
|
566
|
+
return;
|
|
500
567
|
case "decide": {
|
|
501
|
-
const
|
|
568
|
+
const focused = inbox[inboxFocus]?.key.split(":")[0] ?? null;
|
|
569
|
+
const decision = selectDecision(board.decisions, action.target, focused);
|
|
502
570
|
if (!decision) {
|
|
503
|
-
setNotice("Nothing is waiting on a decision.");
|
|
571
|
+
setNotice(action.target ? `No decision matches ${action.target}.` : "Nothing is waiting on a decision.");
|
|
504
572
|
return;
|
|
505
573
|
}
|
|
506
574
|
if (action.dismiss) {
|
|
@@ -544,7 +612,7 @@ export function App({ initial }) {
|
|
|
544
612
|
return;
|
|
545
613
|
}
|
|
546
614
|
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, order, settingsOrder, changeWorkspace,
|
|
547
|
-
config, refresh, suspendTerminal, exit]);
|
|
615
|
+
config, refresh, suspendTerminal, exit, inbox, inboxFocus]);
|
|
548
616
|
useInput((input, key) => {
|
|
549
617
|
if (key.ctrl && input === "c")
|
|
550
618
|
exit();
|
|
@@ -582,7 +650,7 @@ export function App({ initial }) {
|
|
|
582
650
|
return _jsx(Bubble, { message: item.message, width: width }, item.key);
|
|
583
651
|
} }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus }) : null, view === "ticket" && plan.panels > 0 ? ticket
|
|
584
652
|
? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels })
|
|
585
|
-
: _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, inFlight.map((message) => _jsx(Bubble, { message: message, width: width }, message.id)), _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", mode, view === "inbox" ? " ↑↓ scroll" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
|
|
653
|
+
: _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, inFlight.map((message) => _jsx(Bubble, { message: message, width: width }, message.id)), _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter ? _jsxs(Text, { color: UI.warn, children: ["logs ", logsFilter, " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", mode, view === "inbox" ? " ↑↓ scroll" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
|
|
586
654
|
setDraft(next);
|
|
587
655
|
if (editingRef.current)
|
|
588
656
|
setEditing({ key: editingRef.current.key, draft: next });
|
package/dist/tui/Help.js
CHANGED
|
@@ -7,18 +7,20 @@ export const HELP_FOOTER = "Anything not starting with / goes to whoever you are
|
|
|
7
7
|
export const COMMANDS = [
|
|
8
8
|
{ name: "/board", help: "the kanban board" },
|
|
9
9
|
{ name: "/inbox", help: "decisions and messages waiting on you" },
|
|
10
|
-
{ name: "/ticket", args: "HD-12", help: "open
|
|
10
|
+
{ name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open or create a ticket" },
|
|
11
11
|
{ name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
|
|
12
12
|
{ name: "/cancel", args: "HD-12", help: "cancel a ticket" },
|
|
13
|
-
{ name: "/
|
|
13
|
+
{ name: "/msg", args: "HD-12 TEXT", help: "message a ticket's builder" },
|
|
14
|
+
{ name: "/logs", args: "[HD-12]", help: "filter or clear the activity stream" },
|
|
15
|
+
{ name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
|
|
14
16
|
{ name: "/epics", help: "list epics and ticket progress" },
|
|
15
17
|
{ name: "/architect", help: "talk to the agent that shapes draft epics" },
|
|
16
18
|
{ name: "/plan", help: "alias for /architect" },
|
|
17
|
-
{ name: "/decide", args: "
|
|
19
|
+
{ name: "/decide", args: "[N|ID] answer", help: "answer a targeted or focused decision" },
|
|
18
20
|
{ name: "/agents", args: "[add ... | rm ROLE|ID]", help: "view or manage agents" },
|
|
19
21
|
{ name: "/env", help: "list workspace environment variable names" },
|
|
20
22
|
{ name: "/settings", help: "change provider caps and agent settings" },
|
|
21
|
-
{ name: "/workspace", args: "[slug | new | set | rotate-key]", help: "list, switch, create, or configure" },
|
|
23
|
+
{ name: "/workspace", args: "[slug | new | set | rotate-key | grant-runner-access]", help: "list, switch, create, or configure" },
|
|
22
24
|
{ name: "/on", help: "turn on the current workspace" },
|
|
23
25
|
{ name: "/off", help: "turn off the current workspace" },
|
|
24
26
|
{ name: "/feed", help: "what just happened" },
|
package/dist/tui/data.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
|
|
1
|
+
import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
|
|
2
2
|
import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
|
|
3
3
|
import { readEpicSpec } from "../epics.js";
|
|
4
4
|
export const POLL_MS = 5_000;
|
|
@@ -120,6 +120,12 @@ export async function createEpicFromFile(config, path) {
|
|
|
120
120
|
export async function approveEpic(config, id) {
|
|
121
121
|
return approveEpicNow(id, config);
|
|
122
122
|
}
|
|
123
|
+
export async function deleteEpic(config, id) {
|
|
124
|
+
return removeEpic(id, config);
|
|
125
|
+
}
|
|
126
|
+
export async function postTicketMessage(config, key, body) {
|
|
127
|
+
return sendMessage({ ticket_key: key, body_md: body, to_role: "builder", delivery: "queue" }, config);
|
|
128
|
+
}
|
|
123
129
|
export async function queueTicket(config, key) {
|
|
124
130
|
return queueTicketNow(key, config);
|
|
125
131
|
}
|
|
@@ -166,7 +172,10 @@ export async function updateProviderCap(config, provider, cap) {
|
|
|
166
172
|
export async function updateWorkspace(config, fields) {
|
|
167
173
|
return patchWorkspace(fields, config);
|
|
168
174
|
}
|
|
169
|
-
export async function loadLiveEvents(config, board) {
|
|
175
|
+
export async function loadLiveEvents(config, board, ticketKey) {
|
|
176
|
+
if (ticketKey)
|
|
177
|
+
return (await listTicketEvents(ticketKey, undefined, undefined, config)).events
|
|
178
|
+
.sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
|
|
170
179
|
const liveIds = new Set(board.runs.filter((run) => run.status === "running").map((run) => run.id));
|
|
171
180
|
const keys = new Set(board.runs
|
|
172
181
|
.filter((run) => liveIds.has(run.id) && run.ticket_id)
|
|
@@ -183,6 +192,14 @@ export function decisionOptions(decision) {
|
|
|
183
192
|
return decision.options.filter((value) => typeof value === "string");
|
|
184
193
|
return [];
|
|
185
194
|
}
|
|
195
|
+
export function selectDecision(decisions, target, focusedId) {
|
|
196
|
+
if (!target)
|
|
197
|
+
return decisions.find((decision) => decision.id === focusedId) ?? decisions[0] ?? null;
|
|
198
|
+
if (/^\d+$/.test(target))
|
|
199
|
+
return decisions[Number(target) - 1] ?? null;
|
|
200
|
+
const matches = decisions.filter((decision) => decision.id.toLowerCase().startsWith(target.toLowerCase()));
|
|
201
|
+
return matches.length === 1 ? matches[0] : null;
|
|
202
|
+
}
|
|
186
203
|
export function statusLabel(status) {
|
|
187
204
|
return status.replaceAll("_", " ");
|
|
188
205
|
}
|
package/dist/tui/parse.js
CHANGED
|
@@ -44,12 +44,16 @@ export function parseLine(raw) {
|
|
|
44
44
|
case "workspace":
|
|
45
45
|
case "ws": {
|
|
46
46
|
const command = rest[0]?.toLowerCase();
|
|
47
|
-
if (command && ["new", "set", "rotate-key"].includes(command)) {
|
|
47
|
+
if (command && ["new", "set", "rotate-key", "grant-runner-access"].includes(command)) {
|
|
48
48
|
return { kind: "workspace-command", command: command, args: rest.slice(1) };
|
|
49
49
|
}
|
|
50
50
|
return { kind: "workspace", slug: argument || null };
|
|
51
51
|
}
|
|
52
52
|
case "ticket":
|
|
53
|
+
if (rest[0]?.toLowerCase() === "new") {
|
|
54
|
+
const args = rest.slice(1);
|
|
55
|
+
return { kind: "ticket-new", args: args.some((arg) => arg.startsWith("--")) ? args : args.length ? [args.join(" ")] : [] };
|
|
56
|
+
}
|
|
53
57
|
return argument
|
|
54
58
|
? { kind: "ticket", key: argument.toUpperCase() }
|
|
55
59
|
: { kind: "unknown", command: "ticket needs a key" };
|
|
@@ -59,7 +63,9 @@ export function parseLine(raw) {
|
|
|
59
63
|
}
|
|
60
64
|
return rest[0]?.toLowerCase() === "approve" && rest.length === 2
|
|
61
65
|
? { kind: "epic-approve", id: rest[1] }
|
|
62
|
-
:
|
|
66
|
+
: rest[0]?.toLowerCase() === "rm" && rest.length === 2
|
|
67
|
+
? { kind: "epic-rm", id: rest[1] }
|
|
68
|
+
: { kind: "unknown", command: "epic needs new PATH, approve ID, or rm ID" };
|
|
63
69
|
case "epics":
|
|
64
70
|
return { kind: "epics" };
|
|
65
71
|
case "on":
|
|
@@ -75,12 +81,21 @@ export function parseLine(raw) {
|
|
|
75
81
|
: { kind: "unknown", command: "cancel needs a key" };
|
|
76
82
|
case "decide": {
|
|
77
83
|
const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
|
|
84
|
+
const words = argument.replace(/(^|\s)--(skip|dismiss)(\s|$)/g, " ").trim().split(/\s+/).filter(Boolean);
|
|
85
|
+
const targeted = words.length > 1 && (/^\d+$/.test(words[0]) || /^[0-9a-f][0-9a-f-]*$/i.test(words[0]));
|
|
78
86
|
return {
|
|
79
87
|
kind: "decide",
|
|
80
|
-
|
|
88
|
+
target: targeted ? words[0] : null,
|
|
89
|
+
answer: targeted ? words.slice(1).join(" ") : words.join(" "),
|
|
81
90
|
dismiss,
|
|
82
91
|
};
|
|
83
92
|
}
|
|
93
|
+
case "msg":
|
|
94
|
+
return rest.length >= 2 ? { kind: "message", key: rest[0].toUpperCase(), text: rest.slice(1).join(" ") }
|
|
95
|
+
: { kind: "unknown", command: "msg needs KEY TEXT" };
|
|
96
|
+
case "logs":
|
|
97
|
+
return rest.length <= 1 ? { kind: "logs", key: rest[0]?.toUpperCase() ?? null }
|
|
98
|
+
: { kind: "unknown", command: "logs takes one ticket key" };
|
|
84
99
|
case "refresh":
|
|
85
100
|
return { kind: "refresh" };
|
|
86
101
|
case "exit":
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createWorkspace, listWorkspaces, rotateWorkspaceApiKey, updateWorkspace } from "./api.js";
|
|
2
2
|
import { loadConfig, switchWorkspace, writeHdConfig } from "./config.js";
|
|
3
3
|
import { promptOnStdin } from "./prompt.js";
|
|
4
|
-
import { defaultGh, HDX_RUNNER_GH_USER, preflightWorkspace } from "./workspace-preflight.js";
|
|
5
|
-
export const WORKSPACE_USAGE = "usage: hd workspace ls | use SLUG | new --name NAME --repo OWNER/NAME [--create|--no-create] [flags] | set [flags] | rotate-key";
|
|
4
|
+
import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, preflightWorkspace, runnerAccessForRepoOwner, runnerRepoPermission } from "./workspace-preflight.js";
|
|
5
|
+
export const WORKSPACE_USAGE = "usage: hd workspace ls | use SLUG | new --name NAME --repo OWNER/NAME [--create|--no-create] [flags] | set [flags] | rotate-key | grant-runner-access [--runner-user USER]";
|
|
6
6
|
function flags(argv) {
|
|
7
7
|
const opts = {};
|
|
8
8
|
const bools = new Set();
|
|
@@ -165,6 +165,33 @@ export async function workspaceUse(argv) {
|
|
|
165
165
|
switchWorkspace(selected.slug);
|
|
166
166
|
return selected;
|
|
167
167
|
}
|
|
168
|
+
export async function workspaceGrantRunnerAccess(argv, deps = {}) {
|
|
169
|
+
const { opts, bools, rest } = flags(argv);
|
|
170
|
+
if (rest.length || bools.size || Object.keys(opts).some((name) => name !== "runner-user")) {
|
|
171
|
+
throw new Error(WORKSPACE_USAGE);
|
|
172
|
+
}
|
|
173
|
+
const runnerUser = opts["runner-user"] ?? HDX_RUNNER_GH_USER;
|
|
174
|
+
const current = loadConfig().slug;
|
|
175
|
+
const workspace = (await listWorkspaces()).find((item) => item.slug === current);
|
|
176
|
+
if (!workspace)
|
|
177
|
+
throw new Error(`No workspace ${current}.`);
|
|
178
|
+
const gh = deps.gh ?? defaultGh;
|
|
179
|
+
try {
|
|
180
|
+
await gh(["--version"]);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new Error("GitHub CLI is unavailable. Install it if needed, then run `gh auth login`.");
|
|
184
|
+
}
|
|
185
|
+
const access = await runnerAccessForRepoOwner(workspace.repo, gh);
|
|
186
|
+
await gh(["api", "-X", "PUT", `repos/${workspace.repo}/collaborators/${runnerUser}`,
|
|
187
|
+
"-f", `permission=${access}`]);
|
|
188
|
+
const permission = await runnerRepoPermission(workspace.repo, runnerUser, gh);
|
|
189
|
+
if (!hasWriteRepoPermission(permission)) {
|
|
190
|
+
throw new Error(`Runner ${runnerUser} has ${permission} permission on ${workspace.repo}, expected write or better.`);
|
|
191
|
+
}
|
|
192
|
+
return { repo: workspace.repo, runnerUser, permission, requestedPermission: access };
|
|
193
|
+
}
|
|
194
|
+
export const workspaceGrantRunnerAdmin = workspaceGrantRunnerAccess;
|
|
168
195
|
export async function workspaceRotateKey() {
|
|
169
196
|
const config = loadConfig();
|
|
170
197
|
const { api_key } = await rotateWorkspaceApiKey(config);
|
|
@@ -52,6 +52,25 @@ export async function requireAdminRepo(repo, gh = defaultGh) {
|
|
|
52
52
|
throw new Error(`Repository admin permission is required; viewer has ${permission || "none"}.`);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
export async function runnerRepoPermission(repo, runnerUser = HDX_RUNNER_GH_USER, gh = defaultGh) {
|
|
56
|
+
try {
|
|
57
|
+
const result = JSON.parse(await gh(["api", `repos/${repo}/collaborators/${runnerUser}/permission`]));
|
|
58
|
+
return result.permission ?? "none";
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (/404|not found/i.test(errorMessage(error)))
|
|
62
|
+
return "none";
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function hasWriteRepoPermission(permission) {
|
|
67
|
+
return ["admin", "maintain", "write", "push"].includes(permission.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
export async function runnerAccessForRepoOwner(repo, gh = defaultGh) {
|
|
70
|
+
const owner = repo.split("/")[0];
|
|
71
|
+
const ownerType = (await gh(["api", `users/${owner}`, "--jq", ".type"])).trim();
|
|
72
|
+
return ownerType.toLowerCase() === "organization" ? "admin" : "push";
|
|
73
|
+
}
|
|
55
74
|
async function repoView(repo, gh) {
|
|
56
75
|
return JSON.parse(await gh(["repo", "view", repo, "--json", "defaultBranchRef,isEmpty,viewerPermission"]));
|
|
57
76
|
}
|
|
@@ -82,18 +101,11 @@ export async function preflightWorkspace(input, gh = defaultGh) {
|
|
|
82
101
|
if (!branch)
|
|
83
102
|
throw new Error(`GitHub repository ${input.repo} has no default branch.`);
|
|
84
103
|
const runnerUser = input.runnerUser || HDX_RUNNER_GH_USER;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const result = JSON.parse(await gh(["api", `repos/${input.repo}/collaborators/${runnerUser}/permission`]));
|
|
88
|
-
permission = result.permission ?? "none";
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
if (!/404|not found/i.test(errorMessage(error)))
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
const invitationPending = permission.toLowerCase() !== "admin";
|
|
104
|
+
const permission = await runnerRepoPermission(input.repo, runnerUser, gh);
|
|
105
|
+
const invitationPending = !hasWriteRepoPermission(permission);
|
|
95
106
|
if (invitationPending) {
|
|
96
|
-
|
|
107
|
+
const access = await runnerAccessForRepoOwner(input.repo, gh);
|
|
108
|
+
await gh(["api", "-X", "PUT", `repos/${input.repo}/collaborators/${runnerUser}`, "-f", `permission=${access}`]);
|
|
97
109
|
}
|
|
98
110
|
return { branch, invitationPending };
|
|
99
111
|
}
|