@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import { DEFAULT_API_URL, findCredential, getApiUrl, isExpired, loadConfig, normalizeApiUrl, saveCredential, } from "./config.js";
|
|
3
|
+
import { loadProjectConfig } from "./project-config.js";
|
|
4
|
+
import { success, error, debug, info } from "./output.js";
|
|
5
|
+
import { requestDeviceCode, pollForToken, normalizeExpiresAt, DeviceFlowDeniedError, DeviceFlowExpiredError, } from "./browser-login.js";
|
|
6
|
+
async function getDefaultApiUrl() {
|
|
7
|
+
const existing = await loadConfig();
|
|
8
|
+
return existing?.apiUrl ?? DEFAULT_API_URL;
|
|
9
|
+
}
|
|
10
|
+
async function fetchWhoami(apiUrl, headers) {
|
|
11
|
+
const url = `${apiUrl}/api/v1/whoami`;
|
|
12
|
+
debug(`GET ${url}`);
|
|
13
|
+
const res = await fetch(url, { headers });
|
|
14
|
+
debug(`${res.status} ${res.statusText}`);
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`whoami failed (${res.status})`);
|
|
17
|
+
return (await res.json());
|
|
18
|
+
}
|
|
19
|
+
function printSuccess(data) {
|
|
20
|
+
const membership = data.memberships[0];
|
|
21
|
+
if (membership) {
|
|
22
|
+
success(`Logged in as ${data.user.email} (${membership.name ?? membership.organizationId}, ${membership.role})`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
success(`Logged in as ${data.user.email}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function readStdinLines(n) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const lines = [];
|
|
31
|
+
let buf = "";
|
|
32
|
+
process.stdin.setEncoding("utf-8");
|
|
33
|
+
process.stdin.on("data", (chunk) => {
|
|
34
|
+
buf += chunk;
|
|
35
|
+
const parts = buf.split("\n");
|
|
36
|
+
buf = parts.pop() ?? "";
|
|
37
|
+
for (const line of parts) {
|
|
38
|
+
lines.push(line);
|
|
39
|
+
if (lines.length >= n) {
|
|
40
|
+
process.stdin.pause();
|
|
41
|
+
resolve(lines);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
process.stdin.on("end", () => {
|
|
47
|
+
if (buf.length > 0)
|
|
48
|
+
lines.push(buf);
|
|
49
|
+
resolve(lines);
|
|
50
|
+
});
|
|
51
|
+
process.stdin.resume();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the URL a login flow should target: an explicit `--url` wins and
|
|
56
|
+
* skips the prompt entirely; otherwise the user is prompted with the saved
|
|
57
|
+
* default pre-filled.
|
|
58
|
+
*/
|
|
59
|
+
async function resolveLoginUrl(url) {
|
|
60
|
+
if (url)
|
|
61
|
+
return normalizeApiUrl(url);
|
|
62
|
+
const defaultApiUrl = await getDefaultApiUrl();
|
|
63
|
+
const { apiUrl: rawApiUrl } = await inquirer.prompt([
|
|
64
|
+
{
|
|
65
|
+
type: "input",
|
|
66
|
+
name: "apiUrl",
|
|
67
|
+
message: "API URL:",
|
|
68
|
+
default: defaultApiUrl,
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
return normalizeApiUrl(rawApiUrl);
|
|
72
|
+
}
|
|
73
|
+
export async function loginWithApiKey(opts = {}) {
|
|
74
|
+
let apiKey;
|
|
75
|
+
let apiUrl;
|
|
76
|
+
if (!process.stdin.isTTY) {
|
|
77
|
+
// Non-interactive mode. With --url only the key is read from stdin;
|
|
78
|
+
// without it, two lines: apiKey, then apiUrl.
|
|
79
|
+
if (opts.url) {
|
|
80
|
+
const lines = await readStdinLines(1);
|
|
81
|
+
apiKey = lines[0] ?? "";
|
|
82
|
+
apiUrl = normalizeApiUrl(opts.url);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const defaultApiUrl = await getDefaultApiUrl();
|
|
86
|
+
const lines = await readStdinLines(2);
|
|
87
|
+
apiKey = lines[0] ?? "";
|
|
88
|
+
apiUrl = normalizeApiUrl(lines[1]?.trim() || defaultApiUrl);
|
|
89
|
+
}
|
|
90
|
+
if (!apiKey) {
|
|
91
|
+
error("API key is required");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const prompted = await inquirer.prompt([
|
|
97
|
+
{
|
|
98
|
+
type: "password",
|
|
99
|
+
name: "apiKey",
|
|
100
|
+
message: "API Key:",
|
|
101
|
+
mask: "*",
|
|
102
|
+
validate: (v) => (v.length > 0 ? true : "API key is required"),
|
|
103
|
+
},
|
|
104
|
+
]);
|
|
105
|
+
apiKey = prompted.apiKey;
|
|
106
|
+
apiUrl = await resolveLoginUrl(opts.url);
|
|
107
|
+
}
|
|
108
|
+
const headers = {
|
|
109
|
+
"x-api-key": apiKey,
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
};
|
|
112
|
+
let data;
|
|
113
|
+
try {
|
|
114
|
+
data = await fetchWhoami(apiUrl, headers);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const msg = err.message;
|
|
118
|
+
if (msg.includes("401") || msg.toLowerCase().includes("invalid")) {
|
|
119
|
+
error("API key is invalid or expired.");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
if (msg.includes("fetch") ||
|
|
123
|
+
msg.includes("ECONNREFUSED") ||
|
|
124
|
+
msg.includes("ENOTFOUND")) {
|
|
125
|
+
error(`Could not reach the server at ${apiUrl}. Check the URL and try again.`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
error(`Login failed: ${msg}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
const credential = { type: "apiKey", token: apiKey };
|
|
132
|
+
await saveCredential(apiUrl, credential);
|
|
133
|
+
printSuccess(data);
|
|
134
|
+
}
|
|
135
|
+
export async function loginWithBrowser(opts = {}) {
|
|
136
|
+
const apiUrl = await resolveLoginUrl(opts.url);
|
|
137
|
+
let codeRes;
|
|
138
|
+
try {
|
|
139
|
+
codeRes = await requestDeviceCode(apiUrl);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
const msg = err.message;
|
|
143
|
+
if (msg.includes("fetch") ||
|
|
144
|
+
msg.includes("ECONNREFUSED") ||
|
|
145
|
+
msg.includes("ENOTFOUND")) {
|
|
146
|
+
error(`Could not reach the server at ${apiUrl}. Check the URL and try again.`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
error(`Could not start device login: ${msg}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
const verificationUrl = codeRes.verification_uri_complete ?? codeRes.verification_uri;
|
|
153
|
+
const absoluteUrl = verificationUrl.startsWith("http")
|
|
154
|
+
? verificationUrl
|
|
155
|
+
: `${apiUrl}${verificationUrl}`;
|
|
156
|
+
info(`First copy your one-time code: ${codeRes.user_code}`);
|
|
157
|
+
if (opts.noBrowser) {
|
|
158
|
+
info(`Open this URL in your browser to continue:`);
|
|
159
|
+
info(absoluteUrl);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
info(`Press Enter to open ${absoluteUrl} in your browser…`);
|
|
163
|
+
await inquirer.prompt([{ type: "input", name: "_", message: "" }]);
|
|
164
|
+
if (process.env.HOBBLE_NO_OPEN !== "1") {
|
|
165
|
+
try {
|
|
166
|
+
const { default: open } = await import("open");
|
|
167
|
+
await open(absoluteUrl);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
debug(`open() failed: ${err.message}`);
|
|
171
|
+
// URL is already on stdout — no further action needed
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
info(`Waiting for authorization…`);
|
|
176
|
+
let token;
|
|
177
|
+
try {
|
|
178
|
+
token = await pollForToken({
|
|
179
|
+
apiUrl,
|
|
180
|
+
deviceCode: codeRes.device_code,
|
|
181
|
+
intervalSeconds: codeRes.interval ?? 5,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (err instanceof DeviceFlowDeniedError) {
|
|
186
|
+
error("Authorization denied.");
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
if (err instanceof DeviceFlowExpiredError) {
|
|
190
|
+
error("The login code expired. Run `hobble login` to try again.");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
error(`Login failed: ${err.message}`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
// Better-Auth's device token endpoint returns `expires_in` (seconds) rather than
|
|
197
|
+
// `expires_at`. Fall back to computing the absolute expiry from `expires_in` when
|
|
198
|
+
// `expires_at` is absent.
|
|
199
|
+
const rawExpiresAt = token.expires_at !== undefined
|
|
200
|
+
? token.expires_at
|
|
201
|
+
: token.expires_in !== undefined
|
|
202
|
+
? Date.now() / 1000 + token.expires_in
|
|
203
|
+
: Date.now() / 1000 + 604800; // default: 7 days
|
|
204
|
+
const expiresAt = normalizeExpiresAt(rawExpiresAt);
|
|
205
|
+
const headers = {
|
|
206
|
+
Authorization: `Bearer ${token.access_token}`,
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
};
|
|
209
|
+
let data;
|
|
210
|
+
try {
|
|
211
|
+
data = await fetchWhoami(apiUrl, headers);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
error(`Token validation failed: ${err.message}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
await saveCredential(apiUrl, {
|
|
218
|
+
type: "session",
|
|
219
|
+
token: token.access_token,
|
|
220
|
+
expiresAt,
|
|
221
|
+
});
|
|
222
|
+
printSuccess(data);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Make sure the user is authenticated against the effective instance,
|
|
226
|
+
* starting the browser login flow inline when they aren't.
|
|
227
|
+
*
|
|
228
|
+
* Resolution order for the instance: explicit `url` (e.g. `init --url`) >
|
|
229
|
+
* HOBBLE_API_URL > .hobble apiUrl > global default. Returns the resolved URL.
|
|
230
|
+
*
|
|
231
|
+
* Only prompts on a TTY — non-interactive callers (CI) get the classic
|
|
232
|
+
* "Not logged in" error instead of a hung device-flow poll. HOBBLE_ASSUME_TTY=1
|
|
233
|
+
* lets the e2e harness exercise the interactive path through pipes.
|
|
234
|
+
*/
|
|
235
|
+
export async function ensureAuthenticated(opts = {}) {
|
|
236
|
+
const config = await loadConfig();
|
|
237
|
+
const projectConfig = await loadProjectConfig();
|
|
238
|
+
const apiUrl = opts.url
|
|
239
|
+
? normalizeApiUrl(opts.url)
|
|
240
|
+
: getApiUrl(config, projectConfig?.apiUrl ?? null);
|
|
241
|
+
if (process.env.HOBBLE_API_KEY)
|
|
242
|
+
return apiUrl;
|
|
243
|
+
const credential = await findCredential(apiUrl);
|
|
244
|
+
if (credential && !isExpired(credential))
|
|
245
|
+
return apiUrl;
|
|
246
|
+
const interactive = process.stdin.isTTY || process.env.HOBBLE_ASSUME_TTY === "1";
|
|
247
|
+
if (!interactive) {
|
|
248
|
+
throw new Error(`Not logged in to ${apiUrl}. Run \`hobble login\` first.`);
|
|
249
|
+
}
|
|
250
|
+
info(credential
|
|
251
|
+
? `Your session for ${apiUrl} has expired — signing in again.`
|
|
252
|
+
: `You're not logged in to ${apiUrl} yet — signing in first.`);
|
|
253
|
+
await loginWithBrowser({ url: apiUrl });
|
|
254
|
+
return apiUrl;
|
|
255
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const DEFAULT_API_URL = "http://localhost:3000";
|
|
1
2
|
export type AuthCredential = {
|
|
2
3
|
type: "apiKey";
|
|
3
4
|
token: string;
|
|
@@ -7,14 +8,40 @@ export type AuthCredential = {
|
|
|
7
8
|
expiresAt: string;
|
|
8
9
|
};
|
|
9
10
|
export interface GlobalConfig {
|
|
11
|
+
/** Default instance, used when neither HOBBLE_API_URL nor .hobble names one. */
|
|
10
12
|
apiUrl: string;
|
|
11
|
-
|
|
13
|
+
/** Credentials keyed by instance URL, so logins to different instances coexist. */
|
|
14
|
+
credentials: Record<string, AuthCredential>;
|
|
12
15
|
}
|
|
13
16
|
export declare function loadConfig(): Promise<GlobalConfig | null>;
|
|
14
17
|
export declare function saveConfig(config: GlobalConfig): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Store a credential for one instance. The instance also becomes the global
|
|
20
|
+
* default, so the most recent login is what URL-less commands talk to.
|
|
21
|
+
* Credentials for other instances are preserved.
|
|
22
|
+
*/
|
|
23
|
+
export declare function saveCredential(apiUrl: string, credential: AuthCredential): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Drop the credential for one instance. Removes the config file entirely when
|
|
26
|
+
* it was the last credential; otherwise re-points the default at a remaining
|
|
27
|
+
* instance if the removed one was the default.
|
|
28
|
+
*/
|
|
29
|
+
export declare function removeCredential(apiUrl: string): Promise<void>;
|
|
15
30
|
export declare function deleteConfig(): Promise<void>;
|
|
16
|
-
|
|
17
|
-
export declare function
|
|
31
|
+
/** A session credential past its expiry. API-key credentials never expire here. */
|
|
32
|
+
export declare function isExpired(credential: AuthCredential): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Credential for one instance: HOBBLE_API_KEY always wins (CI escape hatch),
|
|
35
|
+
* otherwise the per-instance entry in the global config.
|
|
36
|
+
*/
|
|
37
|
+
export declare function findCredential(apiUrl: string): Promise<AuthCredential | null>;
|
|
38
|
+
export declare function getAuthCredential(apiUrl: string): Promise<AuthCredential>;
|
|
39
|
+
/**
|
|
40
|
+
* Effective instance URL: HOBBLE_API_URL env > .hobble override (passed in by
|
|
41
|
+
* the caller) > global default > localhost. Callers with a .hobble in scope
|
|
42
|
+
* pass its apiUrl as `projectApiUrl`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function getApiUrl(config: GlobalConfig | null, projectApiUrl?: string | null): string;
|
|
18
45
|
/**
|
|
19
46
|
* Normalize a user-supplied API URL. If the input lacks a scheme (no `://`),
|
|
20
47
|
* `https://` is assumed — typing `secrets.example.com` becomes
|
|
@@ -26,4 +53,3 @@ export declare function getApiUrl(config: GlobalConfig | null): string;
|
|
|
26
53
|
* (typically by falling back to a default).
|
|
27
54
|
*/
|
|
28
55
|
export declare function normalizeApiUrl(input: string): string;
|
|
29
|
-
export declare function requireConfig(): Promise<GlobalConfig>;
|
package/dist/lib/config.js
CHANGED
|
@@ -3,15 +3,11 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const CONFIG_DIR = join(homedir(), ".config", "hobble");
|
|
5
5
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
6
|
-
|
|
6
|
+
export const DEFAULT_API_URL = "http://localhost:3000";
|
|
7
|
+
function isAuthCredential(value) {
|
|
7
8
|
if (!value || typeof value !== "object")
|
|
8
9
|
return false;
|
|
9
|
-
const
|
|
10
|
-
if (typeof v.apiUrl !== "string")
|
|
11
|
-
return false;
|
|
12
|
-
if (!v.auth || typeof v.auth !== "object")
|
|
13
|
-
return false;
|
|
14
|
-
const a = v.auth;
|
|
10
|
+
const a = value;
|
|
15
11
|
if (a.type === "apiKey")
|
|
16
12
|
return typeof a.token === "string";
|
|
17
13
|
if (a.type === "session") {
|
|
@@ -19,14 +15,36 @@ function isGlobalConfig(value) {
|
|
|
19
15
|
}
|
|
20
16
|
return false;
|
|
21
17
|
}
|
|
18
|
+
function isGlobalConfig(value) {
|
|
19
|
+
if (!value || typeof value !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const v = value;
|
|
22
|
+
if (typeof v.apiUrl !== "string")
|
|
23
|
+
return false;
|
|
24
|
+
if (!v.credentials || typeof v.credentials !== "object")
|
|
25
|
+
return false;
|
|
26
|
+
return Object.values(v.credentials).every(isAuthCredential);
|
|
27
|
+
}
|
|
22
28
|
function migrateLegacy(parsed) {
|
|
23
|
-
if (
|
|
29
|
+
if (!parsed || typeof parsed !== "object")
|
|
24
30
|
return null;
|
|
31
|
+
const authShape = parsed;
|
|
32
|
+
if (typeof authShape.apiUrl === "string" && isAuthCredential(authShape.auth)) {
|
|
33
|
+
return {
|
|
34
|
+
apiUrl: authShape.apiUrl,
|
|
35
|
+
credentials: { [authShape.apiUrl]: authShape.auth },
|
|
36
|
+
};
|
|
25
37
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
const keyShape = parsed;
|
|
39
|
+
if (typeof keyShape.apiKey === "string" && typeof keyShape.apiUrl === "string") {
|
|
40
|
+
return {
|
|
41
|
+
apiUrl: keyShape.apiUrl,
|
|
42
|
+
credentials: {
|
|
43
|
+
[keyShape.apiUrl]: { type: "apiKey", token: keyShape.apiKey },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
30
48
|
}
|
|
31
49
|
export async function loadConfig() {
|
|
32
50
|
let raw;
|
|
@@ -56,6 +74,39 @@ export async function saveConfig(config) {
|
|
|
56
74
|
await mkdir(CONFIG_DIR, { recursive: true });
|
|
57
75
|
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
58
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Store a credential for one instance. The instance also becomes the global
|
|
79
|
+
* default, so the most recent login is what URL-less commands talk to.
|
|
80
|
+
* Credentials for other instances are preserved.
|
|
81
|
+
*/
|
|
82
|
+
export async function saveCredential(apiUrl, credential) {
|
|
83
|
+
const existing = await loadConfig();
|
|
84
|
+
await saveConfig({
|
|
85
|
+
apiUrl,
|
|
86
|
+
credentials: { ...existing?.credentials, [apiUrl]: credential },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Drop the credential for one instance. Removes the config file entirely when
|
|
91
|
+
* it was the last credential; otherwise re-points the default at a remaining
|
|
92
|
+
* instance if the removed one was the default.
|
|
93
|
+
*/
|
|
94
|
+
export async function removeCredential(apiUrl) {
|
|
95
|
+
const config = await loadConfig();
|
|
96
|
+
if (!config)
|
|
97
|
+
return;
|
|
98
|
+
const credentials = { ...config.credentials };
|
|
99
|
+
delete credentials[apiUrl];
|
|
100
|
+
const remaining = Object.keys(credentials);
|
|
101
|
+
if (remaining.length === 0) {
|
|
102
|
+
await deleteConfig();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
await saveConfig({
|
|
106
|
+
apiUrl: config.apiUrl === apiUrl ? remaining[0] : config.apiUrl,
|
|
107
|
+
credentials,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
59
110
|
export async function deleteConfig() {
|
|
60
111
|
try {
|
|
61
112
|
await rm(CONFIG_FILE);
|
|
@@ -64,17 +115,38 @@ export async function deleteConfig() {
|
|
|
64
115
|
// already gone
|
|
65
116
|
}
|
|
66
117
|
}
|
|
67
|
-
|
|
118
|
+
/** A session credential past its expiry. API-key credentials never expire here. */
|
|
119
|
+
export function isExpired(credential) {
|
|
120
|
+
if (credential.type !== "session")
|
|
121
|
+
return false;
|
|
122
|
+
const ms = Date.parse(credential.expiresAt);
|
|
123
|
+
return Number.isFinite(ms) && ms <= Date.now();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Credential for one instance: HOBBLE_API_KEY always wins (CI escape hatch),
|
|
127
|
+
* otherwise the per-instance entry in the global config.
|
|
128
|
+
*/
|
|
129
|
+
export async function findCredential(apiUrl) {
|
|
68
130
|
const envKey = process.env.HOBBLE_API_KEY;
|
|
69
131
|
if (envKey)
|
|
70
132
|
return { type: "apiKey", token: envKey };
|
|
71
133
|
const config = await loadConfig();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
return config?.credentials[apiUrl] ?? null;
|
|
135
|
+
}
|
|
136
|
+
export async function getAuthCredential(apiUrl) {
|
|
137
|
+
const credential = await findCredential(apiUrl);
|
|
138
|
+
if (!credential) {
|
|
139
|
+
throw new Error(`Not logged in to ${apiUrl}. Run \`hobble login\` first.`);
|
|
140
|
+
}
|
|
141
|
+
return credential;
|
|
75
142
|
}
|
|
76
|
-
|
|
77
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Effective instance URL: HOBBLE_API_URL env > .hobble override (passed in by
|
|
145
|
+
* the caller) > global default > localhost. Callers with a .hobble in scope
|
|
146
|
+
* pass its apiUrl as `projectApiUrl`.
|
|
147
|
+
*/
|
|
148
|
+
export function getApiUrl(config, projectApiUrl) {
|
|
149
|
+
return (process.env.HOBBLE_API_URL ?? projectApiUrl ?? config?.apiUrl ?? DEFAULT_API_URL);
|
|
78
150
|
}
|
|
79
151
|
/**
|
|
80
152
|
* Normalize a user-supplied API URL. If the input lacks a scheme (no `://`),
|
|
@@ -93,10 +165,3 @@ export function normalizeApiUrl(input) {
|
|
|
93
165
|
const withScheme = trimmed.includes("://") ? trimmed : `https://${trimmed}`;
|
|
94
166
|
return withScheme.replace(/\/+$/, "");
|
|
95
167
|
}
|
|
96
|
-
export async function requireConfig() {
|
|
97
|
-
const config = await loadConfig();
|
|
98
|
-
if (!config) {
|
|
99
|
-
throw new Error("Not logged in. Run `hobble login` first.");
|
|
100
|
-
}
|
|
101
|
-
return config;
|
|
102
|
-
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export interface ProjectConfig {
|
|
2
2
|
project: string;
|
|
3
3
|
environment: string;
|
|
4
|
+
/**
|
|
5
|
+
* Optional per-directory instance override. Takes precedence over the
|
|
6
|
+
* global default apiUrl (but not over HOBBLE_API_URL). Never holds
|
|
7
|
+
* credentials — those live in the user-scoped global config, keyed by URL.
|
|
8
|
+
*/
|
|
9
|
+
apiUrl?: string;
|
|
4
10
|
}
|
|
5
11
|
export declare function loadProjectConfig(): Promise<ProjectConfig | null>;
|
|
6
12
|
export declare function saveProjectConfig(config: ProjectConfig): Promise<void>;
|