@hobble-env/cli 3.3.0 → 3.3.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 +44 -0
- package/SKILL.md +12 -0
- package/dist/bin/hobble.js +3 -1
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +199 -0
- package/dist/commands/set.js +1 -7
- package/dist/lib/api.d.ts +8 -1
- package/dist/lib/api.js +23 -11
- package/dist/lib/stdin.d.ts +2 -0
- package/dist/lib/stdin.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,50 @@ hobble delete DB_HOST # Delete a secret (with confirmation)
|
|
|
76
76
|
hobble delete DB_HOST -y # Skip confirmation
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
### Credentials (org-scoped)
|
|
80
|
+
|
|
81
|
+
Credentials are org-scoped shared logins and API secrets, distinct from the
|
|
82
|
+
per-environment secrets above. Note the noun: `hobble get DB_HOST` reads an
|
|
83
|
+
**environment secret**, while `hobble secret get stripe` (alias of
|
|
84
|
+
`hobble credential get stripe`) reads an **org credential**.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
hobble credential list # List credentials (no values)
|
|
88
|
+
hobble secret list --json # Same, via the alias, as JSON (includes tags)
|
|
89
|
+
|
|
90
|
+
hobble secret get stripe # Print a credential's decrypted value
|
|
91
|
+
hobble secret get stripe --field username # Print the username instead
|
|
92
|
+
hobble secret get stripe --json # Full object (name, username, url, value, tags)
|
|
93
|
+
|
|
94
|
+
hobble secret set stripe sk_live_123 --username ops@acme.com --url https://stripe.com
|
|
95
|
+
echo "sk_live_123" | hobble secret set stripe - # Value from stdin
|
|
96
|
+
hobble secret set stripe sk_test_9 --tag billing --tag prod # Attach org tags
|
|
97
|
+
|
|
98
|
+
hobble secret delete stripe
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Credential names are case-insensitive and unique **per organization** — no
|
|
102
|
+
`--project`/`--env` needed, but the CLI must know which org you mean:
|
|
103
|
+
|
|
104
|
+
- If your login/API key reaches exactly **one** org, it is used automatically.
|
|
105
|
+
- If it reaches **several** orgs, commands fail with an "ambiguous across
|
|
106
|
+
organizations" error listing the candidates — pass `--org <slug|id>`
|
|
107
|
+
(either the org slug or its id works):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
hobble secret get stripe --org acme-prod
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- A project/env-only scoped API token has **no** org-scoped credential
|
|
114
|
+
access and gets a clear error — create an org-scoped token instead.
|
|
115
|
+
|
|
116
|
+
CI usage: when stdout is not a TTY the value is printed raw (no decoration,
|
|
117
|
+
no trailing newline), so command substitution is clean:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
export STRIPE_KEY=$(hobble secret get stripe --field value)
|
|
121
|
+
```
|
|
122
|
+
|
|
79
123
|
### Running Commands with Secrets
|
|
80
124
|
|
|
81
125
|
Fetch secrets from the dashboard and inject them as environment variables into a subprocess:
|
package/SKILL.md
CHANGED
|
@@ -57,9 +57,19 @@ Global options: `-v, --verbose` (debug output on stderr), `-V, --version`.
|
|
|
57
57
|
| `shell` | `--project`, `--env` | Interactive subshell with secrets injected. Sets `HOBBLE_PROJECT`, `HOBBLE_ENVIRONMENT`, `HOBBLE_SHELL=<project>/<env>`; prefixes bash/zsh prompts. Interactive — don't run unprompted. |
|
|
58
58
|
| `env pull` | `--project`, `--env`, `--force`, `-o <path>` (default `.env`) | Writes plaintext secrets + comments to disk. Refuses to overwrite without `--force`. Use sparingly (Rule 2). |
|
|
59
59
|
| `env push` | `--project`, `--env`, `-y`, `-i <path>` (default `.env`) | Diffs file against server, prints create/update/delete lists, confirms unless `-y`. **An empty value (`KEY=`) deletes that secret on the server.** Contiguous `# ...` lines above a key become its comment; comments are only sent when changed. |
|
|
60
|
+
| `credential list` | `--org <slug\|id>`, `--json` | Org-scoped credentials (alias: `secret list`). Names/usernames/URLs, never values. |
|
|
61
|
+
| `credential get <name>` | `--org`, `--field <value\|username\|url>`, `--json` | Decrypted credential (alias: `secret get`). Name match is case-insensitive. Non-TTY output is the raw field value only. |
|
|
62
|
+
| `credential set <name> [value\|-]` | `--org`, `--username`, `--url`, `--comment <text>`, `--tag <name>` (repeatable) | Create-or-update (alias: `secret set`). `-` or omitted value reads stdin. `--tag` **replaces** all existing tags. |
|
|
63
|
+
| `credential delete <name>` | `--org` | Deletes by case-insensitive name (alias: `secret delete`). |
|
|
60
64
|
|
|
61
65
|
All data commands accept `--project <name> --env <name>` to override `.hobble` for a single invocation. Prefer flags over `hobble switch` for one-off reads.
|
|
62
66
|
|
|
67
|
+
## Credentials vs Environment Secrets
|
|
68
|
+
|
|
69
|
+
`hobble get KEY` reads a **per-environment secret** (project/env-scoped, used as ENV vars). `hobble secret get NAME` — alias of `hobble credential get NAME` — reads an **org-scoped credential** (shared logins/API details, not bound to any environment). Do not confuse the two nouns.
|
|
70
|
+
|
|
71
|
+
Credential names are case-insensitive and unique per organization. Credential commands take no `--project`/`--env`; when the login/API key reaches more than one org, pass `--org <slug|id>` (an ambiguity error lists the candidate orgs). Project/env-only scoped tokens cannot access credentials — an org-scoped token is required. In CI, non-TTY output of `hobble secret get` is the raw value only: `export STRIPE_KEY=$(hobble secret get stripe)`.
|
|
72
|
+
|
|
63
73
|
## Composition: Retrieving Items for Other Commands
|
|
64
74
|
|
|
65
75
|
The CLI is designed to be piped. The contract:
|
|
@@ -99,6 +109,8 @@ JSON shapes:
|
|
|
99
109
|
- `list --json`: array of `{ id, key, createdAt, updatedAt, tags: [{ id, name, color }] }` — no values.
|
|
100
110
|
- `get --json`: `{ key, value, tags: [name, ...] }`.
|
|
101
111
|
- `tags --json`: array of `{ id, name, color }`.
|
|
112
|
+
- `credential list --json`: array of `{ id, name, username, url, comment, createdAt, updatedAt, tags: [{ id, name, color }] }` — no values.
|
|
113
|
+
- `credential get --json`: `{ name, username, url, value, comment, tags: [name, ...] }`.
|
|
102
114
|
|
|
103
115
|
All commands exit `0` on success and `1` on any failure (not found, auth, network), with the reason on stderr.
|
|
104
116
|
|
package/dist/bin/hobble.js
CHANGED
|
@@ -10,6 +10,7 @@ import { listCommand } from "../commands/list.js";
|
|
|
10
10
|
import { getCommand } from "../commands/get.js";
|
|
11
11
|
import { setCommand } from "../commands/set.js";
|
|
12
12
|
import { tagsCommand } from "../commands/tags.js";
|
|
13
|
+
import { credentialCommand } from "../commands/credential.js";
|
|
13
14
|
import { deleteCommand } from "../commands/delete.js";
|
|
14
15
|
import { runCommand } from "../commands/run.js";
|
|
15
16
|
import { shellCommand } from "../commands/shell.js";
|
|
@@ -19,7 +20,7 @@ const program = new Command();
|
|
|
19
20
|
program
|
|
20
21
|
.name("hobble")
|
|
21
22
|
.description("CLI for managing secrets via the secret-management-dash API")
|
|
22
|
-
.version("3.
|
|
23
|
+
.version("3.3.1")
|
|
23
24
|
.enablePositionalOptions()
|
|
24
25
|
.option("-v, --verbose", "Show debug output for troubleshooting");
|
|
25
26
|
program.hook("preAction", (thisCommand) => {
|
|
@@ -36,6 +37,7 @@ program.addCommand(listCommand);
|
|
|
36
37
|
program.addCommand(getCommand);
|
|
37
38
|
program.addCommand(setCommand);
|
|
38
39
|
program.addCommand(tagsCommand);
|
|
40
|
+
program.addCommand(credentialCommand);
|
|
39
41
|
program.addCommand(deleteCommand);
|
|
40
42
|
program.addCommand(runCommand);
|
|
41
43
|
program.addCommand(shellCommand);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { api, ApiError } from "../lib/api.js";
|
|
3
|
+
import { success, error, isTTY, table } from "../lib/output.js";
|
|
4
|
+
import { readStdin } from "../lib/stdin.js";
|
|
5
|
+
/**
|
|
6
|
+
* Render credential-specific API errors:
|
|
7
|
+
* - multi-org 400 → list the candidate orgs (`name (slug)`, id fallback when
|
|
8
|
+
* slug is null) and point at --org;
|
|
9
|
+
* - zero-org 403 → surface the server's distinct "no org-scoped credential
|
|
10
|
+
* access" message (NOT an ambiguity prompt); falls back to the canned 403
|
|
11
|
+
* message when the body is absent (older server).
|
|
12
|
+
*/
|
|
13
|
+
function renderCredentialError(err) {
|
|
14
|
+
if (err instanceof ApiError) {
|
|
15
|
+
const body = (err.body ?? {});
|
|
16
|
+
if (err.status === 400 && Array.isArray(body.candidates) && body.candidates.length > 0) {
|
|
17
|
+
const list = body.candidates
|
|
18
|
+
.map((c) => `${c.name} (${c.slug ?? c.id})`)
|
|
19
|
+
.join(", ");
|
|
20
|
+
return `Credential names are ambiguous across organizations — pass --org <slug|id>. Candidates: ${list}`;
|
|
21
|
+
}
|
|
22
|
+
if (err.status === 403 &&
|
|
23
|
+
typeof body.error === "string" &&
|
|
24
|
+
body.error.includes("no org-scoped credential access")) {
|
|
25
|
+
return body.error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return err.message;
|
|
29
|
+
}
|
|
30
|
+
function orgQuery(org) {
|
|
31
|
+
return org ? `?org=${encodeURIComponent(org)}` : "";
|
|
32
|
+
}
|
|
33
|
+
async function listCredentials(org) {
|
|
34
|
+
const res = (await api.get(`/api/v1/credentials${orgQuery(org)}`));
|
|
35
|
+
return res.data;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Case-INSENSITIVE name match — `credential_org_name_unique` is a per-org
|
|
39
|
+
* lower(name) index, so at most one credential matches per org and `STRIPE`
|
|
40
|
+
* must find `stripe`. (Deliberately not the exact `===` match the env-secret
|
|
41
|
+
* `get` uses; env keys are exact-case ENV names, credential names are not.)
|
|
42
|
+
*/
|
|
43
|
+
function findByName(items, name) {
|
|
44
|
+
const lower = name.toLowerCase();
|
|
45
|
+
return items.find((c) => c.name.toLowerCase() === lower);
|
|
46
|
+
}
|
|
47
|
+
const listSubcommand = new Command("list")
|
|
48
|
+
.description("List credentials in the organization")
|
|
49
|
+
.option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
|
|
50
|
+
.option("--json", "Output as JSON")
|
|
51
|
+
.action(async (opts) => {
|
|
52
|
+
try {
|
|
53
|
+
const credentials = await listCredentials(opts.org);
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
process.stdout.write(JSON.stringify(credentials, null, 2) + "\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (credentials.length === 0) {
|
|
59
|
+
console.log("No credentials found.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
table(["Name", "Username", "URL", "Updated"], credentials.map((c) => [
|
|
63
|
+
c.name,
|
|
64
|
+
c.username ?? "",
|
|
65
|
+
c.url ?? "",
|
|
66
|
+
new Date(c.updatedAt).toLocaleDateString(),
|
|
67
|
+
]));
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
error(renderCredentialError(err));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const getSubcommand = new Command("get")
|
|
75
|
+
.description("Get a decrypted credential value")
|
|
76
|
+
.argument("<name>", "Credential name (case-insensitive)")
|
|
77
|
+
.option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
|
|
78
|
+
.option("--field <field>", "Field to output: value, username, or url", "value")
|
|
79
|
+
.option("--json", "Output the full credential as JSON")
|
|
80
|
+
.action(async (name, opts) => {
|
|
81
|
+
try {
|
|
82
|
+
const field = opts.field;
|
|
83
|
+
if (!["value", "username", "url"].includes(field)) {
|
|
84
|
+
error(`Invalid --field "${field}". Must be value, username, or url.`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const match = findByName(await listCredentials(opts.org), name);
|
|
88
|
+
if (!match) {
|
|
89
|
+
error(`Credential "${name}" not found.`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
const res = (await api.get(`/api/v1/credentials/${match.id}`));
|
|
93
|
+
const cred = res.data;
|
|
94
|
+
if (opts.json) {
|
|
95
|
+
process.stdout.write(JSON.stringify({
|
|
96
|
+
name: cred.name,
|
|
97
|
+
username: cred.username,
|
|
98
|
+
url: cred.url,
|
|
99
|
+
value: cred.value,
|
|
100
|
+
comment: cred.comment,
|
|
101
|
+
tags: (cred.tags ?? []).map((t) => t.name),
|
|
102
|
+
}, null, 2) + "\n");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const selected = field === "value" ? cred.value : field === "username" ? cred.username : cred.url;
|
|
106
|
+
if (selected == null) {
|
|
107
|
+
error(`Credential "${cred.name}" has no ${field}.`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
if (isTTY()) {
|
|
111
|
+
const label = field === "value" ? cred.name : `${cred.name}.${field}`;
|
|
112
|
+
console.log(`${label}=${selected}`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// CI-safe: raw value only, no decoration, no trailing newline — so
|
|
116
|
+
// `$(hobble secret get NAME)` substitutes cleanly.
|
|
117
|
+
process.stdout.write(selected);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
error(renderCredentialError(err));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
const setSubcommand = new Command("set")
|
|
126
|
+
.description("Create or update a credential")
|
|
127
|
+
.argument("<name>", "Credential name (case-insensitive)")
|
|
128
|
+
.argument("[value]", "Credential value (use - to read from stdin)")
|
|
129
|
+
.option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
|
|
130
|
+
.option("--username <username>", "Login/identifier for this credential")
|
|
131
|
+
.option("--url <url>", "Related URL")
|
|
132
|
+
.option("--comment <text>", "Comment (use empty string to clear)")
|
|
133
|
+
.option("--tag <name>", "Tag to attach (repeatable; replaces existing tags)", (val, prev) => prev.concat(val), [])
|
|
134
|
+
.action(async (name, rawValue, opts) => {
|
|
135
|
+
try {
|
|
136
|
+
let value;
|
|
137
|
+
if (rawValue === "-" || rawValue === undefined) {
|
|
138
|
+
value = await readStdin();
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
value = rawValue;
|
|
142
|
+
}
|
|
143
|
+
// The same single-org resolution gates create-vs-update: the list call
|
|
144
|
+
// below 400s on multi-org callers without --org, so a write can never
|
|
145
|
+
// target an arbitrarily-chosen org.
|
|
146
|
+
const existing = findByName(await listCredentials(opts.org), name);
|
|
147
|
+
const tags = opts.tag && opts.tag.length ? opts.tag : undefined;
|
|
148
|
+
const fields = { value };
|
|
149
|
+
if (opts.username !== undefined)
|
|
150
|
+
fields.username = opts.username;
|
|
151
|
+
if (opts.url !== undefined)
|
|
152
|
+
fields.url = opts.url;
|
|
153
|
+
if (opts.comment !== undefined)
|
|
154
|
+
fields.comment = opts.comment;
|
|
155
|
+
if (tags)
|
|
156
|
+
fields.tags = tags;
|
|
157
|
+
if (existing) {
|
|
158
|
+
await api.put(`/api/v1/credentials/${existing.id}`, fields);
|
|
159
|
+
success(`Updated "${existing.name}"`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
await api.post(`/api/v1/credentials${orgQuery(opts.org)}`, {
|
|
163
|
+
name,
|
|
164
|
+
...fields,
|
|
165
|
+
});
|
|
166
|
+
success(`Created "${name}"`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
error(renderCredentialError(err));
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
const deleteSubcommand = new Command("delete")
|
|
175
|
+
.description("Delete a credential")
|
|
176
|
+
.argument("<name>", "Credential name (case-insensitive)")
|
|
177
|
+
.option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
|
|
178
|
+
.action(async (name, opts) => {
|
|
179
|
+
try {
|
|
180
|
+
const match = findByName(await listCredentials(opts.org), name);
|
|
181
|
+
if (!match) {
|
|
182
|
+
error(`Credential "${name}" not found.`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
await api.delete(`/api/v1/credentials/${match.id}`);
|
|
186
|
+
success(`Deleted "${match.name}"`);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
error(renderCredentialError(err));
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
export const credentialCommand = new Command("credential")
|
|
194
|
+
.alias("secret")
|
|
195
|
+
.description("Manage org-scoped credentials (shared logins/API secrets — distinct from per-environment secrets)")
|
|
196
|
+
.addCommand(listSubcommand)
|
|
197
|
+
.addCommand(getSubcommand)
|
|
198
|
+
.addCommand(setSubcommand)
|
|
199
|
+
.addCommand(deleteSubcommand);
|
package/dist/commands/set.js
CHANGED
|
@@ -3,13 +3,7 @@ import { api } from "../lib/api.js";
|
|
|
3
3
|
import { resolveContext } from "../lib/project-config.js";
|
|
4
4
|
import { resolveProjectId } from "./list.js";
|
|
5
5
|
import { success, error } from "../lib/output.js";
|
|
6
|
-
|
|
7
|
-
const chunks = [];
|
|
8
|
-
for await (const chunk of process.stdin) {
|
|
9
|
-
chunks.push(chunk);
|
|
10
|
-
}
|
|
11
|
-
return Buffer.concat(chunks).toString("utf-8").trimEnd();
|
|
12
|
-
}
|
|
6
|
+
import { readStdin } from "../lib/stdin.js";
|
|
13
7
|
export const setCommand = new Command("set")
|
|
14
8
|
.description("Create or update a secret")
|
|
15
9
|
.argument("<key>", "Secret key name")
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export declare class ApiError extends Error {
|
|
2
2
|
status: number;
|
|
3
|
-
|
|
3
|
+
body?: unknown | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* `body` is the parsed JSON error body (when the server sent one). The
|
|
6
|
+
* thrown `message` strings are unchanged — commands that need structured
|
|
7
|
+
* fields (e.g. the credentials multi-org 400's `candidates` array, or the
|
|
8
|
+
* zero-org 403's distinct `error` string) inspect `status` + `body`.
|
|
9
|
+
*/
|
|
10
|
+
constructor(status: number, message: string, body?: unknown | undefined);
|
|
4
11
|
}
|
|
5
12
|
/**
|
|
6
13
|
* Pin the instance URL for the rest of this process. Used by `hobble init
|
package/dist/lib/api.js
CHANGED
|
@@ -3,9 +3,17 @@ import { loadProjectConfig } from "./project-config.js";
|
|
|
3
3
|
import { debug } from "./output.js";
|
|
4
4
|
export class ApiError extends Error {
|
|
5
5
|
status;
|
|
6
|
-
|
|
6
|
+
body;
|
|
7
|
+
/**
|
|
8
|
+
* `body` is the parsed JSON error body (when the server sent one). The
|
|
9
|
+
* thrown `message` strings are unchanged — commands that need structured
|
|
10
|
+
* fields (e.g. the credentials multi-org 400's `candidates` array, or the
|
|
11
|
+
* zero-org 403's distinct `error` string) inspect `status` + `body`.
|
|
12
|
+
*/
|
|
13
|
+
constructor(status, message, body) {
|
|
7
14
|
super(message);
|
|
8
15
|
this.status = status;
|
|
16
|
+
this.body = body;
|
|
9
17
|
this.name = "ApiError";
|
|
10
18
|
}
|
|
11
19
|
}
|
|
@@ -84,18 +92,22 @@ async function request(method, path, body) {
|
|
|
84
92
|
throw new Error(`Could not reach the server at ${baseUrl}. Check your connection or run \`hobble login\` to update the URL.`);
|
|
85
93
|
}
|
|
86
94
|
debug(`${res.status} ${res.statusText}`);
|
|
87
|
-
if (res.status === 401) {
|
|
88
|
-
if (credentialType === "session") {
|
|
89
|
-
throw new ApiError(401, "Your session has expired. Run `hobble login` to sign in again.");
|
|
90
|
-
}
|
|
91
|
-
throw new ApiError(401, "API key is invalid or expired. Run `hobble login` to update.");
|
|
92
|
-
}
|
|
93
|
-
if (res.status === 403) {
|
|
94
|
-
throw new ApiError(403, "You don't have permission to perform this action.");
|
|
95
|
-
}
|
|
96
95
|
if (!res.ok) {
|
|
96
|
+
// Parse the error body once, up front, so structured fields survive the
|
|
97
|
+
// canned-message short-circuits below (attached as ApiError.body).
|
|
97
98
|
const data = await res.json().catch(() => ({}));
|
|
98
|
-
|
|
99
|
+
if (res.status === 401) {
|
|
100
|
+
if (credentialType === "session") {
|
|
101
|
+
throw new ApiError(401, "Your session has expired. Run `hobble login` to sign in again.", data);
|
|
102
|
+
}
|
|
103
|
+
throw new ApiError(401, "API key is invalid or expired. Run `hobble login` to update.", data);
|
|
104
|
+
}
|
|
105
|
+
if (res.status === 403) {
|
|
106
|
+
// Keep the canned message: the server's generic 403 body is just
|
|
107
|
+
// {"error":"Forbidden"} — preferring it would regress every command.
|
|
108
|
+
throw new ApiError(403, "You don't have permission to perform this action.", data);
|
|
109
|
+
}
|
|
110
|
+
throw new ApiError(res.status, data.error ?? `Request failed with status ${res.status}`, data);
|
|
99
111
|
}
|
|
100
112
|
if (res.status === 204)
|
|
101
113
|
return {};
|