@hobble-env/cli 3.1.0 → 3.3.1
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 +78 -17
- package/SKILL.md +99 -17
- 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/init.js +20 -5
- package/dist/commands/login.js +4 -211
- package/dist/commands/logout.js +38 -7
- package/dist/commands/set.js +1 -7
- package/dist/commands/switch.js +8 -2
- package/dist/commands/whoami.js +11 -4
- package/dist/lib/api.d.ts +14 -1
- package/dist/lib/api.js +48 -15
- package/dist/lib/auth-flow.d.ts +21 -0
- package/dist/lib/auth-flow.js +255 -0
- package/dist/lib/config.d.ts +30 -4
- package/dist/lib/config.js +90 -25
- package/dist/lib/project-config.d.ts +6 -0
- package/dist/lib/stdin.d.ts +2 -0
- package/dist/lib/stdin.js +8 -0
- package/package.json +1 -1
package/dist/commands/login.js
CHANGED
|
@@ -1,223 +1,16 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import
|
|
3
|
-
import { loadConfig, saveConfig, normalizeApiUrl, } from "../lib/config.js";
|
|
4
|
-
import { success, error, debug, info } from "../lib/output.js";
|
|
5
|
-
import { requestDeviceCode, pollForToken, normalizeExpiresAt, DeviceFlowDeniedError, DeviceFlowExpiredError, } from "../lib/browser-login.js";
|
|
6
|
-
const DEFAULT_API_URL = "http://localhost:3000";
|
|
7
|
-
async function getDefaultApiUrl() {
|
|
8
|
-
const existing = await loadConfig();
|
|
9
|
-
return existing?.apiUrl ?? DEFAULT_API_URL;
|
|
10
|
-
}
|
|
11
|
-
async function fetchWhoami(apiUrl, headers) {
|
|
12
|
-
const url = `${apiUrl}/api/v1/whoami`;
|
|
13
|
-
debug(`GET ${url}`);
|
|
14
|
-
const res = await fetch(url, { headers });
|
|
15
|
-
debug(`${res.status} ${res.statusText}`);
|
|
16
|
-
if (!res.ok)
|
|
17
|
-
throw new Error(`whoami failed (${res.status})`);
|
|
18
|
-
return (await res.json());
|
|
19
|
-
}
|
|
20
|
-
function printSuccess(data) {
|
|
21
|
-
const membership = data.memberships[0];
|
|
22
|
-
if (membership) {
|
|
23
|
-
success(`Logged in as ${data.user.email} (${membership.name ?? membership.organizationId}, ${membership.role})`);
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
success(`Logged in as ${data.user.email}`);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async function readStdinLines(n) {
|
|
30
|
-
return new Promise((resolve) => {
|
|
31
|
-
const lines = [];
|
|
32
|
-
let buf = "";
|
|
33
|
-
process.stdin.setEncoding("utf-8");
|
|
34
|
-
process.stdin.on("data", (chunk) => {
|
|
35
|
-
buf += chunk;
|
|
36
|
-
const parts = buf.split("\n");
|
|
37
|
-
buf = parts.pop() ?? "";
|
|
38
|
-
for (const line of parts) {
|
|
39
|
-
lines.push(line);
|
|
40
|
-
if (lines.length >= n) {
|
|
41
|
-
process.stdin.pause();
|
|
42
|
-
resolve(lines);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
process.stdin.on("end", () => {
|
|
48
|
-
if (buf.length > 0)
|
|
49
|
-
lines.push(buf);
|
|
50
|
-
resolve(lines);
|
|
51
|
-
});
|
|
52
|
-
process.stdin.resume();
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
async function loginWithApiKey() {
|
|
56
|
-
let answers;
|
|
57
|
-
const defaultApiUrl = await getDefaultApiUrl();
|
|
58
|
-
if (!process.stdin.isTTY) {
|
|
59
|
-
// Non-interactive mode: read two lines from stdin (apiKey, apiUrl)
|
|
60
|
-
const lines = await readStdinLines(2);
|
|
61
|
-
const apiKey = lines[0] ?? "";
|
|
62
|
-
const rawApiUrl = lines[1]?.trim() || defaultApiUrl;
|
|
63
|
-
if (!apiKey) {
|
|
64
|
-
error("API key is required");
|
|
65
|
-
process.exit(1);
|
|
66
|
-
}
|
|
67
|
-
answers = { apiKey, apiUrl: normalizeApiUrl(rawApiUrl) };
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
const prompted = await inquirer.prompt([
|
|
71
|
-
{
|
|
72
|
-
type: "password",
|
|
73
|
-
name: "apiKey",
|
|
74
|
-
message: "API Key:",
|
|
75
|
-
mask: "*",
|
|
76
|
-
validate: (v) => (v.length > 0 ? true : "API key is required"),
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
type: "input",
|
|
80
|
-
name: "apiUrl",
|
|
81
|
-
message: "API URL:",
|
|
82
|
-
default: defaultApiUrl,
|
|
83
|
-
},
|
|
84
|
-
]);
|
|
85
|
-
answers = { apiKey: prompted.apiKey, apiUrl: normalizeApiUrl(prompted.apiUrl) };
|
|
86
|
-
}
|
|
87
|
-
const headers = {
|
|
88
|
-
"x-api-key": answers.apiKey,
|
|
89
|
-
"Content-Type": "application/json",
|
|
90
|
-
};
|
|
91
|
-
let data;
|
|
92
|
-
try {
|
|
93
|
-
data = await fetchWhoami(answers.apiUrl, headers);
|
|
94
|
-
}
|
|
95
|
-
catch (err) {
|
|
96
|
-
const msg = err.message;
|
|
97
|
-
if (msg.includes("401") || msg.toLowerCase().includes("invalid")) {
|
|
98
|
-
error("API key is invalid or expired.");
|
|
99
|
-
process.exit(1);
|
|
100
|
-
}
|
|
101
|
-
if (msg.includes("fetch") ||
|
|
102
|
-
msg.includes("ECONNREFUSED") ||
|
|
103
|
-
msg.includes("ENOTFOUND")) {
|
|
104
|
-
error(`Could not reach the server at ${answers.apiUrl}. Check the URL and try again.`);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
error(`Login failed: ${msg}`);
|
|
108
|
-
process.exit(1);
|
|
109
|
-
}
|
|
110
|
-
const credential = { type: "apiKey", token: answers.apiKey };
|
|
111
|
-
await saveConfig({ apiUrl: answers.apiUrl, auth: credential });
|
|
112
|
-
printSuccess(data);
|
|
113
|
-
}
|
|
114
|
-
async function loginWithBrowser(opts) {
|
|
115
|
-
const defaultApiUrl = await getDefaultApiUrl();
|
|
116
|
-
const { apiUrl: rawApiUrl } = await inquirer.prompt([
|
|
117
|
-
{
|
|
118
|
-
type: "input",
|
|
119
|
-
name: "apiUrl",
|
|
120
|
-
message: "API URL:",
|
|
121
|
-
default: defaultApiUrl,
|
|
122
|
-
},
|
|
123
|
-
]);
|
|
124
|
-
const apiUrl = normalizeApiUrl(rawApiUrl);
|
|
125
|
-
let codeRes;
|
|
126
|
-
try {
|
|
127
|
-
codeRes = await requestDeviceCode(apiUrl);
|
|
128
|
-
}
|
|
129
|
-
catch (err) {
|
|
130
|
-
const msg = err.message;
|
|
131
|
-
if (msg.includes("fetch") ||
|
|
132
|
-
msg.includes("ECONNREFUSED") ||
|
|
133
|
-
msg.includes("ENOTFOUND")) {
|
|
134
|
-
error(`Could not reach the server at ${apiUrl}. Check the URL and try again.`);
|
|
135
|
-
process.exit(1);
|
|
136
|
-
}
|
|
137
|
-
error(`Could not start device login: ${msg}`);
|
|
138
|
-
process.exit(1);
|
|
139
|
-
}
|
|
140
|
-
const verificationUrl = codeRes.verification_uri_complete ?? codeRes.verification_uri;
|
|
141
|
-
const absoluteUrl = verificationUrl.startsWith("http")
|
|
142
|
-
? verificationUrl
|
|
143
|
-
: `${apiUrl}${verificationUrl}`;
|
|
144
|
-
info(`First copy your one-time code: ${codeRes.user_code}`);
|
|
145
|
-
if (opts.noBrowser) {
|
|
146
|
-
info(`Open this URL in your browser to continue:`);
|
|
147
|
-
info(absoluteUrl);
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
info(`Press Enter to open ${absoluteUrl} in your browser…`);
|
|
151
|
-
await inquirer.prompt([{ type: "input", name: "_", message: "" }]);
|
|
152
|
-
if (process.env.HOBBLE_NO_OPEN !== "1") {
|
|
153
|
-
try {
|
|
154
|
-
const { default: open } = await import("open");
|
|
155
|
-
await open(absoluteUrl);
|
|
156
|
-
}
|
|
157
|
-
catch (err) {
|
|
158
|
-
debug(`open() failed: ${err.message}`);
|
|
159
|
-
// URL is already on stdout — no further action needed
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
info(`Waiting for authorization…`);
|
|
164
|
-
let token;
|
|
165
|
-
try {
|
|
166
|
-
token = await pollForToken({
|
|
167
|
-
apiUrl,
|
|
168
|
-
deviceCode: codeRes.device_code,
|
|
169
|
-
intervalSeconds: codeRes.interval ?? 5,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
catch (err) {
|
|
173
|
-
if (err instanceof DeviceFlowDeniedError) {
|
|
174
|
-
error("Authorization denied.");
|
|
175
|
-
process.exit(1);
|
|
176
|
-
}
|
|
177
|
-
if (err instanceof DeviceFlowExpiredError) {
|
|
178
|
-
error("The login code expired. Run `hobble login` to try again.");
|
|
179
|
-
process.exit(1);
|
|
180
|
-
}
|
|
181
|
-
error(`Login failed: ${err.message}`);
|
|
182
|
-
process.exit(1);
|
|
183
|
-
}
|
|
184
|
-
// Better-Auth's device token endpoint returns `expires_in` (seconds) rather than
|
|
185
|
-
// `expires_at`. Fall back to computing the absolute expiry from `expires_in` when
|
|
186
|
-
// `expires_at` is absent.
|
|
187
|
-
const rawExpiresAt = token.expires_at !== undefined
|
|
188
|
-
? token.expires_at
|
|
189
|
-
: token.expires_in !== undefined
|
|
190
|
-
? Date.now() / 1000 + token.expires_in
|
|
191
|
-
: Date.now() / 1000 + 604800; // default: 7 days
|
|
192
|
-
const expiresAt = normalizeExpiresAt(rawExpiresAt);
|
|
193
|
-
const headers = {
|
|
194
|
-
Authorization: `Bearer ${token.access_token}`,
|
|
195
|
-
"Content-Type": "application/json",
|
|
196
|
-
};
|
|
197
|
-
let data;
|
|
198
|
-
try {
|
|
199
|
-
data = await fetchWhoami(apiUrl, headers);
|
|
200
|
-
}
|
|
201
|
-
catch (err) {
|
|
202
|
-
error(`Token validation failed: ${err.message}`);
|
|
203
|
-
process.exit(1);
|
|
204
|
-
}
|
|
205
|
-
await saveConfig({
|
|
206
|
-
apiUrl,
|
|
207
|
-
auth: { type: "session", token: token.access_token, expiresAt },
|
|
208
|
-
});
|
|
209
|
-
printSuccess(data);
|
|
210
|
-
}
|
|
2
|
+
import { loginWithApiKey, loginWithBrowser } from "../lib/auth-flow.js";
|
|
211
3
|
export const loginCommand = new Command("login")
|
|
212
4
|
.description("Authenticate with the secret management API")
|
|
5
|
+
.option("--url <url>", "Instance URL to sign in to (skips the URL prompt)")
|
|
213
6
|
.option("--api-key", "Sign in with a long-lived API key (instead of browser session)")
|
|
214
7
|
.option("--no-browser", "Skip opening the browser; print the URL only")
|
|
215
8
|
.action(async (opts) => {
|
|
216
9
|
// Commander represents `--no-browser` as `opts.browser === false`
|
|
217
10
|
const noBrowser = opts.browser === false;
|
|
218
11
|
if (opts.apiKey) {
|
|
219
|
-
await loginWithApiKey();
|
|
12
|
+
await loginWithApiKey({ url: opts.url });
|
|
220
13
|
return;
|
|
221
14
|
}
|
|
222
|
-
await loginWithBrowser({ noBrowser });
|
|
15
|
+
await loginWithBrowser({ url: opts.url, noBrowser });
|
|
223
16
|
});
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,25 +1,56 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import inquirer from "inquirer";
|
|
3
|
-
import { deleteConfig, loadConfig } from "../lib/config.js";
|
|
3
|
+
import { deleteConfig, getApiUrl, loadConfig, removeCredential, } from "../lib/config.js";
|
|
4
|
+
import { loadProjectConfig } from "../lib/project-config.js";
|
|
4
5
|
import { success, info } from "../lib/output.js";
|
|
5
6
|
export const logoutCommand = new Command("logout")
|
|
6
|
-
.description("Remove stored credentials")
|
|
7
|
-
.
|
|
7
|
+
.description("Remove stored credentials for the current instance")
|
|
8
|
+
.option("--all", "Remove stored credentials for every instance")
|
|
9
|
+
.action(async (opts) => {
|
|
8
10
|
const config = await loadConfig();
|
|
9
|
-
|
|
11
|
+
const instances = config ? Object.keys(config.credentials) : [];
|
|
12
|
+
if (!config || instances.length === 0) {
|
|
10
13
|
info("Not logged in.");
|
|
11
14
|
return;
|
|
12
15
|
}
|
|
16
|
+
if (opts.all) {
|
|
17
|
+
const { confirm } = await inquirer.prompt([
|
|
18
|
+
{
|
|
19
|
+
type: "confirm",
|
|
20
|
+
name: "confirm",
|
|
21
|
+
message: `Remove stored credentials for ${instances.length === 1 ? "1 instance" : `${instances.length} instances`}?`,
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
if (!confirm)
|
|
26
|
+
return;
|
|
27
|
+
await deleteConfig();
|
|
28
|
+
success("Logged out everywhere.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const projectConfig = await loadProjectConfig();
|
|
32
|
+
const apiUrl = getApiUrl(config, projectConfig?.apiUrl ?? null);
|
|
33
|
+
if (!config.credentials[apiUrl]) {
|
|
34
|
+
info(`Not logged in to ${apiUrl}.`);
|
|
35
|
+
if (instances.length > 0) {
|
|
36
|
+
info(`Stored credentials exist for: ${instances.join(", ")} — use \`hobble logout --all\` to remove them.`);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
13
40
|
const { confirm } = await inquirer.prompt([
|
|
14
41
|
{
|
|
15
42
|
type: "confirm",
|
|
16
43
|
name: "confirm",
|
|
17
|
-
message:
|
|
44
|
+
message: `Remove stored credentials for ${apiUrl}?`,
|
|
18
45
|
default: false,
|
|
19
46
|
},
|
|
20
47
|
]);
|
|
21
48
|
if (!confirm)
|
|
22
49
|
return;
|
|
23
|
-
await
|
|
24
|
-
|
|
50
|
+
await removeCredential(apiUrl);
|
|
51
|
+
const remaining = instances.filter((u) => u !== apiUrl);
|
|
52
|
+
success(`Logged out of ${apiUrl}.`);
|
|
53
|
+
if (remaining.length > 0) {
|
|
54
|
+
info(`Still logged in to: ${remaining.join(", ")} — use \`hobble logout --all\` to remove everything.`);
|
|
55
|
+
}
|
|
25
56
|
});
|
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/commands/switch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { api } from "../lib/api.js";
|
|
3
|
-
import { saveProjectConfig } from "../lib/project-config.js";
|
|
3
|
+
import { loadProjectConfig, saveProjectConfig } from "../lib/project-config.js";
|
|
4
4
|
import { success, error } from "../lib/output.js";
|
|
5
5
|
export const switchCommand = new Command("switch")
|
|
6
6
|
.description("Switch project and environment")
|
|
@@ -27,7 +27,13 @@ export const switchCommand = new Command("switch")
|
|
|
27
27
|
error(`Environment "${envName}" not found in project "${projectName}".`);
|
|
28
28
|
process.exit(1);
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
// Keep an instance pin from a previous `init --url` intact.
|
|
31
|
+
const existing = await loadProjectConfig();
|
|
32
|
+
await saveProjectConfig({
|
|
33
|
+
project: projectName,
|
|
34
|
+
environment: envName,
|
|
35
|
+
...(existing?.apiUrl ? { apiUrl: existing.apiUrl } : {}),
|
|
36
|
+
});
|
|
31
37
|
success(`Switched to ${projectName}/${envName}`);
|
|
32
38
|
}
|
|
33
39
|
catch (err) {
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { api } from "../lib/api.js";
|
|
3
|
-
import { loadConfig } from "../lib/config.js";
|
|
3
|
+
import { getApiUrl, loadConfig } from "../lib/config.js";
|
|
4
|
+
import { loadProjectConfig } from "../lib/project-config.js";
|
|
4
5
|
import { error } from "../lib/output.js";
|
|
5
6
|
function describeExpiry(expiresAt) {
|
|
6
7
|
const ms = Date.parse(expiresAt);
|
|
@@ -31,10 +32,16 @@ export const whoamiCommand = new Command("whoami")
|
|
|
31
32
|
console.log("Org: (no memberships)");
|
|
32
33
|
}
|
|
33
34
|
const config = await loadConfig();
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const projectConfig = await loadProjectConfig();
|
|
36
|
+
const apiUrl = getApiUrl(config, projectConfig?.apiUrl ?? null);
|
|
37
|
+
console.log(`Instance: ${apiUrl}`);
|
|
38
|
+
const credential = process.env.HOBBLE_API_KEY
|
|
39
|
+
? { type: "apiKey" }
|
|
40
|
+
: config?.credentials[apiUrl];
|
|
41
|
+
if (credential?.type === "session") {
|
|
42
|
+
console.log(`Auth: session (${describeExpiry(credential.expiresAt)})`);
|
|
36
43
|
}
|
|
37
|
-
else if (
|
|
44
|
+
else if (credential?.type === "apiKey") {
|
|
38
45
|
console.log(`Auth: api key`);
|
|
39
46
|
}
|
|
40
47
|
}
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
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
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Pin the instance URL for the rest of this process. Used by `hobble init
|
|
14
|
+
* --url` so its API calls hit the instance it just authenticated against,
|
|
15
|
+
* before any .hobble file exists to record it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function setApiUrlOverride(url: string): void;
|
|
5
18
|
export declare const api: {
|
|
6
19
|
get: (path: string) => Promise<unknown>;
|
|
7
20
|
post: (path: string, body: unknown) => Promise<unknown>;
|
package/dist/lib/api.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { loadConfig, getApiUrl, getAuthCredential } from "./config.js";
|
|
2
|
+
import { loadProjectConfig } from "./project-config.js";
|
|
2
3
|
import { debug } from "./output.js";
|
|
3
4
|
export class ApiError extends Error {
|
|
4
5
|
status;
|
|
5
|
-
|
|
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) {
|
|
6
14
|
super(message);
|
|
7
15
|
this.status = status;
|
|
16
|
+
this.body = body;
|
|
8
17
|
this.name = "ApiError";
|
|
9
18
|
}
|
|
10
19
|
}
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
let apiUrlOverride = null;
|
|
21
|
+
/**
|
|
22
|
+
* Pin the instance URL for the rest of this process. Used by `hobble init
|
|
23
|
+
* --url` so its API calls hit the instance it just authenticated against,
|
|
24
|
+
* before any .hobble file exists to record it.
|
|
25
|
+
*/
|
|
26
|
+
export function setApiUrlOverride(url) {
|
|
27
|
+
apiUrlOverride = url;
|
|
28
|
+
}
|
|
29
|
+
async function getHeaders(baseUrl) {
|
|
30
|
+
const credential = await getAuthCredential(baseUrl);
|
|
13
31
|
if (credential.type === "apiKey") {
|
|
14
32
|
return {
|
|
15
33
|
headers: {
|
|
@@ -29,13 +47,24 @@ async function getHeaders() {
|
|
|
29
47
|
}
|
|
30
48
|
let _baseUrlLogged = false;
|
|
31
49
|
async function getBaseUrl() {
|
|
50
|
+
if (apiUrlOverride) {
|
|
51
|
+
if (!_baseUrlLogged) {
|
|
52
|
+
_baseUrlLogged = true;
|
|
53
|
+
debug(`Config: API URL pinned for this run (${apiUrlOverride})`);
|
|
54
|
+
}
|
|
55
|
+
return apiUrlOverride;
|
|
56
|
+
}
|
|
32
57
|
const config = await loadConfig();
|
|
33
|
-
const
|
|
58
|
+
const projectConfig = await loadProjectConfig();
|
|
59
|
+
const url = getApiUrl(config, projectConfig?.apiUrl ?? null);
|
|
34
60
|
if (!_baseUrlLogged) {
|
|
35
61
|
_baseUrlLogged = true;
|
|
36
62
|
if (process.env.HOBBLE_API_URL) {
|
|
37
63
|
debug(`Config: API URL from HOBBLE_API_URL (${url})`);
|
|
38
64
|
}
|
|
65
|
+
else if (projectConfig?.apiUrl) {
|
|
66
|
+
debug(`Config: API URL from .hobble (${url})`);
|
|
67
|
+
}
|
|
39
68
|
else if (config?.apiUrl) {
|
|
40
69
|
debug(`Config: API URL from ~/.config/hobble/config.json (${url})`);
|
|
41
70
|
}
|
|
@@ -47,7 +76,7 @@ async function getBaseUrl() {
|
|
|
47
76
|
}
|
|
48
77
|
async function request(method, path, body) {
|
|
49
78
|
const baseUrl = await getBaseUrl();
|
|
50
|
-
const { headers, credentialType } = await getHeaders();
|
|
79
|
+
const { headers, credentialType } = await getHeaders(baseUrl);
|
|
51
80
|
const fullUrl = `${baseUrl}${path}`;
|
|
52
81
|
debug(`${method} ${fullUrl}`);
|
|
53
82
|
let res;
|
|
@@ -63,18 +92,22 @@ async function request(method, path, body) {
|
|
|
63
92
|
throw new Error(`Could not reach the server at ${baseUrl}. Check your connection or run \`hobble login\` to update the URL.`);
|
|
64
93
|
}
|
|
65
94
|
debug(`${res.status} ${res.statusText}`);
|
|
66
|
-
if (res.status === 401) {
|
|
67
|
-
if (credentialType === "session") {
|
|
68
|
-
throw new ApiError(401, "Your session has expired. Run `hobble login` to sign in again.");
|
|
69
|
-
}
|
|
70
|
-
throw new ApiError(401, "API key is invalid or expired. Run `hobble login` to update.");
|
|
71
|
-
}
|
|
72
|
-
if (res.status === 403) {
|
|
73
|
-
throw new ApiError(403, "You don't have permission to perform this action.");
|
|
74
|
-
}
|
|
75
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).
|
|
76
98
|
const data = await res.json().catch(() => ({}));
|
|
77
|
-
|
|
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);
|
|
78
111
|
}
|
|
79
112
|
if (res.status === 204)
|
|
80
113
|
return {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare function loginWithApiKey(opts?: {
|
|
2
|
+
url?: string;
|
|
3
|
+
}): Promise<void>;
|
|
4
|
+
export declare function loginWithBrowser(opts?: {
|
|
5
|
+
url?: string;
|
|
6
|
+
noBrowser?: boolean;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Make sure the user is authenticated against the effective instance,
|
|
10
|
+
* starting the browser login flow inline when they aren't.
|
|
11
|
+
*
|
|
12
|
+
* Resolution order for the instance: explicit `url` (e.g. `init --url`) >
|
|
13
|
+
* HOBBLE_API_URL > .hobble apiUrl > global default. Returns the resolved URL.
|
|
14
|
+
*
|
|
15
|
+
* Only prompts on a TTY — non-interactive callers (CI) get the classic
|
|
16
|
+
* "Not logged in" error instead of a hung device-flow poll. HOBBLE_ASSUME_TTY=1
|
|
17
|
+
* lets the e2e harness exercise the interactive path through pipes.
|
|
18
|
+
*/
|
|
19
|
+
export declare function ensureAuthenticated(opts?: {
|
|
20
|
+
url?: string;
|
|
21
|
+
}): Promise<string>;
|