@higherdev/cli 0.14.5 → 0.15.2

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
@@ -39,6 +39,7 @@ workspace-map config shapes are migrated automatically when they are read.
39
39
  | `hd workspace new --name NAME --repo OWNER/NAME [options]` | Preflight GitHub, wire the runner, and create a paused workspace |
40
40
  | `hd workspace set [options]` | Update settings; max turns uses `--max-turns KIND=N[,KIND=N...]` |
41
41
  | `hd workspace rotate-key` | Rotate the shared API key and save it locally |
42
+ | `hd workspace grant-runner-access [--runner-user USER]` | Grant and confirm write-or-better access for the workspace runner |
42
43
  | `hd agents` | List agents |
43
44
  | `hd agents add ROLE --provider P --model M [options]` | Add an agent |
44
45
  | `hd agents rm ROLE\|ID` | Remove an unambiguous agent |
@@ -47,6 +48,9 @@ workspace-map config shapes are migrated automatically when they are read.
47
48
  | `hd env ls` | List workspace environment variable names |
48
49
  | `hd env set NAME=VALUE [NAME=VALUE...]` | Set workspace environment variables |
49
50
  | `hd env rm NAME` | Remove a workspace environment variable |
51
+ | `hd host env ls` | List environment variable names on the workspace's default host |
52
+ | `hd host env set NAME=VALUE [NAME=VALUE...]` | Set host-only environment variables |
53
+ | `hd host env rm NAME` | Remove a host-only environment variable |
50
54
  | `hd logs KEY [-f]` | Show or follow run events |
51
55
  | `hd msg KEY "message" [--interrupt]` | Message a builder |
52
56
  | `hd decide` | List open decisions |
@@ -62,15 +66,25 @@ elsewhere. If the runner environment or service unit already exists, inspect the
62
66
  pass `--force` only when replacing that host configuration is intentional.
63
67
 
64
68
  `hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
65
- branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` unless
66
- `--runner-user USER` overrides it. On a terminal, missing name or repo flags start a guided wizard. If
69
+ branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` with push
70
+ access for user-owned repositories or admin for organization-owned repositories unless `--runner-user USER`
71
+ overrides it. On a terminal, missing name or repo flags start a guided wizard. If
67
72
  the repository is missing, approve private creation interactively, use `--create` to force it, or
68
73
  `--no-create` to fail. Fully flagged calls remain non-interactive for scripts and Mel.
69
74
 
75
+ For workspaces created before runner access was wired automatically, run
76
+ `hd workspace grant-runner-access`. It uses the operator's authenticated `gh` account, defaults to
77
+ `mel-ilotus`, grants push on user-owned repositories or admin on organization-owned repositories, and
78
+ confirms write-or-better access through GitHub's collaborator permission endpoint. The old
79
+ `grant-runner-admin` name remains an alias.
80
+
70
81
  `hd env set` and `hd env rm` also update GitHub Actions secrets on the current workspace repository.
71
82
  They require the operator's authenticated `gh` account to have repository admin permission. Secret values are sent
72
83
  to `gh` through stdin and are never printed.
73
84
 
85
+ Use `hd host env set SUPABASE_ACCESS_TOKEN=... SUPABASE_ORG_ID=...` once to let the runner provision
86
+ Supabase for workspaces assigned to that host. Host values are never passed to builder processes.
87
+
74
88
  Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/cancel`, `/epic new`,
75
89
  `/epic approve`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
76
90
  `/workspace new`, `/workspace set`, `/workspace rotate-key`, `/feed`,
package/dist/api.js CHANGED
@@ -114,6 +114,15 @@ export async function setWorkspaceEnv(name, value, config = loadConfig()) {
114
114
  export async function removeWorkspaceEnv(name, config = loadConfig()) {
115
115
  return request(config, "DELETE", `/api/w/${config.slug}/env`, { name });
116
116
  }
117
+ export async function listHostEnv(host, config = loadConfig()) {
118
+ return request(config, "GET", `/api/hosts/${encodeURIComponent(host)}/env`);
119
+ }
120
+ export async function setHostEnv(host, name, value, config = loadConfig()) {
121
+ return request(config, "PUT", `/api/hosts/${encodeURIComponent(host)}/env`, { name, value });
122
+ }
123
+ export async function removeHostEnv(host, name, config = loadConfig()) {
124
+ return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
125
+ }
117
126
  export async function listEpics(config = loadConfig()) {
118
127
  return request(config, "GET", `/api/w/${config.slug}/epics`);
119
128
  }
package/dist/index.js CHANGED
@@ -1,14 +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, createAgent, createEpic, createTicket, deleteAgent, deleteEpic, getStatus, listAgents, listEpics, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, setPaused, setWorkspaceEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
4
+ import { approveEpic, answerDecision, cancelTicket, createAgent, createEpic, createTicket, 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 { WORKSPACE_USAGE, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
11
- import { defaultGh, requireAdminRepo } from "./workspace-preflight.js";
10
+ import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
11
+ import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
12
12
  function fail(message) {
13
13
  console.error(message);
14
14
  process.exit(1);
@@ -258,6 +258,11 @@ async function cmdWorkspace(argv, deps = {}) {
258
258
  console.log(`${workspace.slug} ${workspace.name} ${workspace.repo}`);
259
259
  return;
260
260
  }
261
+ if (action === "grant-runner-access" || action === "grant-runner-admin") {
262
+ const result = await workspaceGrantRunnerAccess(rest, deps);
263
+ console.log(`${result.runnerUser} ${result.permission} ${result.repo}`);
264
+ return;
265
+ }
261
266
  if (action === "rotate-key") {
262
267
  if (rest.length)
263
268
  fail(WORKSPACE_USAGE);
@@ -269,6 +274,13 @@ async function cmdWorkspace(argv, deps = {}) {
269
274
  if (action === "set") {
270
275
  const workspace = await workspaceSet(rest);
271
276
  console.log(`${workspace.slug} ${workspace.name} ${workspace.repo}`);
277
+ try {
278
+ const permission = await runnerRepoPermission(workspace.repo, HDX_RUNNER_GH_USER, deps.gh ?? defaultGh);
279
+ if (!hasWriteRepoPermission(permission)) {
280
+ console.log(`hint: runner ${HDX_RUNNER_GH_USER} has ${permission} permission; run hd workspace grant-runner-access`);
281
+ }
282
+ }
283
+ catch { }
272
284
  return;
273
285
  }
274
286
  if (action !== "new")
@@ -412,6 +424,36 @@ export async function cmdEnv(argv, deps = {}) {
412
424
  }
413
425
  fail("usage: hd env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME");
414
426
  }
427
+ async function cmdHost(argv) {
428
+ const [scope, action, ...args] = argv;
429
+ const usage = "usage: hd host env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
430
+ if (scope !== "env")
431
+ fail(usage);
432
+ const host = (await getStatus()).workspace.default_host;
433
+ if (action === "ls") {
434
+ const { env } = await listHostEnv(host);
435
+ if (!env.length)
436
+ return console.log(c.dim(`No environment variables for host ${host}.`));
437
+ console.log(table(["NAME", "UPDATED"], env.map((row) => [row.name, row.updated_at])));
438
+ return;
439
+ }
440
+ if (action === "set" && args.length) {
441
+ for (const assignment of args) {
442
+ const at = assignment.indexOf("=");
443
+ if (at < 1)
444
+ fail(usage);
445
+ await setHostEnv(host, assignment.slice(0, at), assignment.slice(at + 1));
446
+ }
447
+ console.log(`set ${args.length} host variable${args.length === 1 ? "" : "s"} on ${host}`);
448
+ return;
449
+ }
450
+ if (action === "rm" && args.length === 1) {
451
+ await removeHostEnv(host, args[0]);
452
+ console.log(`removed ${args[0]} from ${host}`);
453
+ return;
454
+ }
455
+ fail(usage);
456
+ }
415
457
  export async function main(argv = process.argv.slice(2), deps = {}) {
416
458
  const [cmd, ...rest] = argv;
417
459
  try {
@@ -477,6 +519,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
477
519
  await cmdEnv(rest, deps);
478
520
  return;
479
521
  }
522
+ if (cmd === "host") {
523
+ await cmdHost(rest);
524
+ return;
525
+ }
480
526
  if (cmd === "pause" || cmd === "off") {
481
527
  await setPaused(true);
482
528
  console.log(cmd === "off" ? "off" : "paused");
package/dist/out.js CHANGED
@@ -67,10 +67,11 @@ export function usage() {
67
67
  ` ${c.blue("hd ticket list | show | new | 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`,
74
+ ` ${c.blue("hd host env ls | set | rm")} host environment`,
74
75
  ` ${c.blue("hd logs KEY [-f]")} run events`,
75
76
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
76
77
  ` ${c.blue("hd decide [ID --answer TEXT]")} decisions`,
@@ -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,12 +52,28 @@ 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
- async function repoView(repo, gh) {
56
- return JSON.parse(await gh(["repo", "view", repo, "--json", "defaultBranchRef,isEmpty,viewerPermission"]));
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
+ }
57
65
  }
58
- function canPush(permission) {
66
+ export function hasWriteRepoPermission(permission) {
59
67
  return ["admin", "maintain", "write", "push"].includes(permission.toLowerCase());
60
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
+ }
74
+ async function repoView(repo, gh) {
75
+ return JSON.parse(await gh(["repo", "view", repo, "--json", "defaultBranchRef,isEmpty,viewerPermission"]));
76
+ }
61
77
  export async function preflightWorkspace(input, gh = defaultGh) {
62
78
  try {
63
79
  await gh(["--version"]);
@@ -85,18 +101,11 @@ export async function preflightWorkspace(input, gh = defaultGh) {
85
101
  if (!branch)
86
102
  throw new Error(`GitHub repository ${input.repo} has no default branch.`);
87
103
  const runnerUser = input.runnerUser || HDX_RUNNER_GH_USER;
88
- let permission = "none";
89
- try {
90
- const result = JSON.parse(await gh(["api", `repos/${input.repo}/collaborators/${runnerUser}/permission`]));
91
- permission = result.permission ?? "none";
92
- }
93
- catch (error) {
94
- if (!/404|not found/i.test(errorMessage(error)))
95
- throw error;
96
- }
97
- const invitationPending = !canPush(permission);
104
+ const permission = await runnerRepoPermission(input.repo, runnerUser, gh);
105
+ const invitationPending = !hasWriteRepoPermission(permission);
98
106
  if (invitationPending) {
99
- await gh(["api", "-X", "PUT", `repos/${input.repo}/collaborators/${runnerUser}`, "-f", "permission=push"]);
107
+ const access = await runnerAccessForRepoOwner(input.repo, gh);
108
+ await gh(["api", "-X", "PUT", `repos/${input.repo}/collaborators/${runnerUser}`, "-f", `permission=${access}`]);
100
109
  }
101
110
  return { branch, invitationPending };
102
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.14.5",
3
+ "version": "0.15.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"