@hobble-env/cli 3.0.1 → 3.3.0

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
@@ -34,30 +34,30 @@ npm run hobble -- <command>
34
34
 
35
35
  ## Getting Started
36
36
 
37
- ### 1. Create an API Key
38
-
39
- Generate an API key from the dashboard under **Settings > API Keys**.
40
-
41
- ### 2. Log In
37
+ One command does it all:
42
38
 
43
39
  ```bash
44
- hobble login
40
+ hobble init --url https://secrets.your-company.dev
45
41
  ```
46
42
 
47
- You'll be prompted for your API key and the dashboard URL (defaults to `http://localhost:3000`).
43
+ If you aren't signed in to that instance yet (or your session expired), `init` starts the browser login first, then walks you through selecting a project and environment, saves the choice to a `.hobble` file in your current directory, and pins the instance URL in it. Drop that line in a project README and a new teammate is set up in one step.
48
44
 
49
- ### 3. Initialize a Project
45
+ Without `--url`, `init` targets your default instance (`http://localhost:3000` until you've logged in somewhere). You can switch project/environment later with:
50
46
 
51
47
  ```bash
52
- hobble init
48
+ hobble switch my-project/staging
53
49
  ```
54
50
 
55
- This walks you through selecting a project and environment, then saves the choice to a `.hobble` file in your current directory. You can switch later with:
51
+ ### Logging in separately
56
52
 
57
53
  ```bash
58
- hobble switch my-project/staging
54
+ hobble login # browser device flow; prompts for the URL
55
+ hobble login --url https://secrets.your-company.dev
56
+ hobble login --api-key # paste a long-lived API key instead
59
57
  ```
60
58
 
59
+ API keys are generated from the dashboard under **Settings > API Keys**. Credentials are stored per instance, so logging in to a second instance doesn't sign you out of the first.
60
+
61
61
  ## Commands
62
62
 
63
63
  ### Secrets
@@ -122,24 +122,41 @@ hobble env push -i .env.local # Push from a custom path
122
122
  ### Account
123
123
 
124
124
  ```bash
125
- hobble whoami # Show current user and org info
126
- hobble logout # Remove stored credentials
125
+ hobble whoami # Show current user, org, and instance info
126
+ hobble logout # Remove credentials for the current instance
127
+ hobble logout --all # Remove credentials for every instance
127
128
  ```
128
129
 
129
130
  ## Configuration
130
131
 
131
132
  ### Global Config
132
133
 
133
- Stored at `~/.config/hobble/config.json` after `hobble login`. Contains your API key and dashboard URL.
134
+ Stored at `~/.config/hobble/config.json` after `hobble login` or `hobble init`. Holds your default instance URL and one credential per instance:
135
+
136
+ ```json
137
+ {
138
+ "apiUrl": "https://secrets.your-company.dev",
139
+ "credentials": {
140
+ "https://secrets.your-company.dev": { "type": "session", "token": "…", "expiresAt": "…" },
141
+ "http://localhost:3000": { "type": "apiKey", "token": "…" }
142
+ }
143
+ }
144
+ ```
134
145
 
135
146
  Override with environment variables:
136
147
 
137
- - `HOBBLE_API_KEY` -- override the stored API key
138
- - `HOBBLE_API_URL` -- override the stored dashboard URL
148
+ - `HOBBLE_API_KEY` -- override the stored credential
149
+ - `HOBBLE_API_URL` -- override the instance URL
139
150
 
140
151
  ### Project Config
141
152
 
142
- The `.hobble` file in your project directory stores the default project and environment. Add it to `.gitignore` -- `hobble init` will offer to do this for you.
153
+ The `.hobble` file in your project directory stores the default project and environment, and optionally pins an instance URL (written by `hobble init --url`):
154
+
155
+ ```json
156
+ { "project": "my-app", "environment": "staging", "apiUrl": "https://secrets.your-company.dev" }
157
+ ```
158
+
159
+ A pinned `apiUrl` takes precedence over the global default (but not over `HOBBLE_API_URL`), so different checkouts can talk to different instances. Credentials never live in `.hobble`. Add it to `.gitignore` -- `hobble init` will offer to do this for you.
143
160
 
144
161
  Override per-command with flags:
145
162
 
package/SKILL.md CHANGED
@@ -5,6 +5,10 @@ description: Use when a repository contains a `.hobble` file, references the hob
5
5
 
6
6
  # Using the hobble CLI
7
7
 
8
+ > **Living document.** This file is the canonical agent-facing reference for the hobble CLI surface. Per the gate in `AGENTS.md`, any feature work that changes the CLI surface (commands, flags, arguments, output formats, exit behavior, env vars, or config files) MUST update this file in the same change, and it must be verified current before every PR. Source of truth: `cli/bin/hobble.ts` and `cli/commands/*.ts`.
9
+
10
+ <!-- documented-version: 3.2.0 — keep in sync with cli/bin/hobble.ts -->
11
+
8
12
  ## Overview
9
13
 
10
14
  `hobble` fetches secrets from a remote hobble dashboard and makes them available to local processes. Secrets are never stored on disk unless a user explicitly runs `hobble env pull`. The safe default is in-memory injection via `hobble run`.
@@ -12,13 +16,15 @@ description: Use when a repository contains a `.hobble` file, references the hob
12
16
  ## Detecting hobble in a Repo
13
17
 
14
18
  Treat a repo as a hobble consumer if any of these are true:
15
- - A `.hobble` file exists in the repo root. It is JSON: `{ "project": "...", "environment": "..." }`.
19
+ - A `.hobble` file exists in the repo root. It is JSON: `{ "project": "...", "environment": "..." }`, optionally with an `"apiUrl"` pinning the instance for that directory.
16
20
  - `.gitignore` lists `.hobble`.
17
21
  - Scripts, Makefiles, or docs invoke `hobble ...`.
18
22
  - `package.json` has a script wrapping `hobble run`.
19
23
 
20
24
  If none of these are present, the repo is not a hobble consumer — do not introduce `hobble` without asking.
21
25
 
26
+ In this repo, the CLI runs from source: `npm run hobble -- <command>` (or `npx tsx cli/bin/hobble.ts <command>`). Elsewhere it is the globally installed `hobble` binary from `@hobble-env/cli`.
27
+
22
28
  ## Critical Rules
23
29
 
24
30
  1. **Never commit `.hobble`, `.env`, `.env.local`, or any file produced by `hobble env pull`.** Before running pull, confirm `.gitignore` covers the output path.
@@ -31,22 +37,71 @@ If none of these are present, the repo is not a hobble consumer — do not intro
31
37
  5. **Do not run interactive commands unprompted.** `hobble login`, `hobble init`, and `hobble switch` modify user or repo state. Ask the user to run them, or explicitly confirm before doing so.
32
38
  6. **Never run `hobble env push -y` without showing the diff first.** Omit `-y` (or run once without it) so the user can review create/update/delete counts.
33
39
 
34
- ## Quick Reference
35
-
36
- | Task | Command |
37
- |------|---------|
38
- | Run code with secrets injected | `hobble run -- <cmd>` |
39
- | List secret names (no values) | `hobble list` |
40
- | Read one value | `hobble get KEY` |
41
- | Create or update a secret | `printf '%s' "$v" \| hobble set KEY -` |
42
- | Delete a secret | `hobble delete KEY -y` |
43
- | Write `.env` file (use sparingly) | `hobble env pull` |
44
- | Upload local `.env` to dashboard | `hobble env push` (review diff before `-y`) |
45
- | Change bound project/env | `hobble switch <project>/<env>` |
46
- | Who am I / which org | `hobble whoami` |
40
+ ## Command Surface (v3.2.0)
41
+
42
+ Global options: `-v, --verbose` (debug output on stderr), `-V, --version`.
43
+
44
+ | Command | Arguments / flags | Notes |
45
+ |---------|-------------------|-------|
46
+ | `login` | `--url <url>`, `--api-key`, `--no-browser` | Interactive. Browser device flow by default; `--api-key` for a long-lived key. `--url` targets an instance and skips the URL prompt. Credentials are stored per instance URL in `~/.config/hobble/config.json`; the URL becomes the global default. |
47
+ | `logout` | `--all` | Removes stored credentials for the effective instance only; other instances stay logged in. `--all` removes every credential. |
48
+ | `whoami` | — | User, org, role, effective instance URL, and auth type (api key vs. session + expiry). |
49
+ | `init` | `--url <url>` | Interactive one-stop setup: signs in first when not authenticated (or session expired) against the effective instance, then selects project/env, writes `.hobble`, offers to gitignore it. `--url` pins the instance into `.hobble` (`"apiUrl"`). Non-TTY without credentials exits 1 with `Not logged in to <url>`. |
50
+ | `switch <project>/<env>` | — | Rewrites `.hobble` after validating both exist. Preserves an `"apiUrl"` pin. |
51
+ | `list` | `--project`, `--env`, `--json`, `--tag <name>` (repeatable) | Names + tags + dates, never values. `--tag` is an AND filter, case-insensitive. |
52
+ | `get <key>` | `--project`, `--env`, `--json` | Decrypted value. Output shape depends on TTY — see Composition. |
53
+ | `set <key> [value\|-]` | `--project`, `--env`, `--comment <text>`, `--tag <name>` (repeatable) | Create-or-update. `-` or omitted value reads stdin (trailing whitespace trimmed). `--tag` **replaces** all existing tags. `--comment ""` clears the comment. |
54
+ | `tags` | `--project`, `--env`, `--json` | Tags available to the project (name + color). `--env` accepted but unused. |
55
+ | `delete <key>` | `--project`, `--env`, `-y` | Confirmation prompt unless `-y`. |
56
+ | `run <cmd...>` | `--project`, `--env` | Injects all secrets into a subprocess (in-memory, `shell: true`). Use `--` before the command so its flags aren't parsed by hobble. Exits with the child's exit code. |
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
+ | `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
+ | `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. |
47
60
 
48
61
  All data commands accept `--project <name> --env <name>` to override `.hobble` for a single invocation. Prefer flags over `hobble switch` for one-off reads.
49
62
 
63
+ ## Composition: Retrieving Items for Other Commands
64
+
65
+ The CLI is designed to be piped. The contract:
66
+
67
+ - **stdout carries only data** — JSON (with `--json`) or a raw secret value (`get` when piped).
68
+ - **stderr carries everything human-facing** — tables, `✓`/`ℹ`/`⚠` status lines, and `--verbose` debug output.
69
+
70
+ So `--json` output and `2>/dev/null`-free piping are always safe; you never need to strip decoration from stdout.
71
+
72
+ `hobble get KEY` is TTY-sensitive:
73
+ - Piped / captured (non-TTY): writes the **raw value only, no trailing newline** — safe for command substitution.
74
+ - Interactive terminal: prints `KEY=value` plus a `# tags: ...` line.
75
+
76
+ Patterns:
77
+
78
+ ```bash
79
+ # Feed a value directly to a consumer — never through echo or a variable you print
80
+ hobble get DB_PASS | docker login --password-stdin -u "$user"
81
+
82
+ # Enumerate keys for scripting
83
+ hobble list --json | jq -r '.[].key'
84
+
85
+ # Keys carrying a given tag (server-side list, client-side AND filter)
86
+ hobble list --tag database --json | jq -r '.[].key'
87
+
88
+ # Structured single-secret read: {"key", "value", "tags": [...]}
89
+ hobble get DB_HOST --json | jq -r '.value'
90
+
91
+ # Tag inventory for a project
92
+ hobble tags --json | jq -r '.[].name'
93
+
94
+ # Copy one secret between environments without it touching disk or argv
95
+ hobble get API_KEY --env staging | hobble set API_KEY - --env production
96
+ ```
97
+
98
+ JSON shapes:
99
+ - `list --json`: array of `{ id, key, createdAt, updatedAt, tags: [{ id, name, color }] }` — no values.
100
+ - `get --json`: `{ key, value, tags: [name, ...] }`.
101
+ - `tags --json`: array of `{ id, name, color }`.
102
+
103
+ All commands exit `0` on success and `1` on any failure (not found, auth, network), with the reason on stderr.
104
+
50
105
  ## Context Resolution
51
106
 
52
107
  Each data command needs a `project` and `environment`, resolved in order:
@@ -58,9 +113,11 @@ If you hit that error, stop and ask the user. Do not run `hobble init` on their
58
113
 
59
114
  ## Authentication
60
115
 
61
- - Stored config: `~/.config/hobble/config.json` (written by `hobble login`).
116
+ - Stored config: `~/.config/hobble/config.json` (written by `hobble login` / `hobble init`). Shape: `{ "apiUrl": "<default instance>", "credentials": { "<instance url>": { "type": "apiKey" | "session", ... } } }` — one credential per instance URL, so logins to multiple instances coexist. Older single-credential config files migrate automatically on first read.
117
+ - Instance URL resolution order: `HOBBLE_API_URL` env var > `"apiUrl"` in `.hobble` > global default in config.json > `http://localhost:3000`. The credential used is the one stored for the resolved URL (`HOBBLE_API_KEY` overrides everything).
62
118
  - Overrides via env vars: `HOBBLE_API_KEY`, `HOBBLE_API_URL`. Prefer these in CI.
63
- - A 401 error means the key is stale — tell the user to run `hobble login` rather than re-running it yourself.
119
+ - A 401 means the credential is stale or expired — tell the user to run `hobble login` rather than re-running it yourself. `hobble whoami` shows the effective instance, auth type, and session expiry.
120
+ - `HOBBLE_ASSUME_TTY=1` makes `init` treat piped stdin as interactive (test harness escape hatch — do not use it to bypass Rule 5).
64
121
 
65
122
  ## Common Mistakes
66
123
 
@@ -68,10 +125,13 @@ If you hit that error, stop and ask the user. Do not run `hobble init` on their
68
125
  |---------|------------------|
69
126
  | `hobble set KEY "mysecret"` | `printf '%s' "$v" \| hobble set KEY -` — args leak to shell history |
70
127
  | `hobble env pull` to "inspect" secrets | Use `hobble list` for keys; `hobble get KEY` piped to the consumer for one value |
128
+ | Parsing `hobble list` table output | Tables go to stderr and aren't stable. Use `--json` + `jq` |
129
+ | `hobble set KEY - --tag new` to "add" a tag | `--tag` replaces the full tag set — pass every tag the secret should keep |
130
+ | Pushing a `.env` containing `KEY=` lines | Empty value = **delete on server**. Review the diff (no `-y`) before confirming |
71
131
  | `git add -A` after `hobble env pull` | Verify `.gitignore`, then stage files by name |
72
132
  | Running `hobble login` / `hobble init` to "set things up" | Ask the user — both are interactive and user-scoped |
73
133
  | Committing `.hobble` because it has no secrets | It binds the repo to a project/env and should always be gitignored |
74
- | `hobble env push -y` on a dirty `.env` | Run without `-y` first to see the create/update/delete diff |
134
+ | `hobble run npm test -- --grep foo` flag confusion | Put `--` after `run`: `hobble run -- npm test` so hobble doesn't eat the flags |
75
135
 
76
136
  ## Running Tests That Need Secrets
77
137
 
@@ -89,3 +149,13 @@ hobble env pull && npm test
89
149
  ## When hobble Is Missing
90
150
 
91
151
  If `hobble` is not installed but the repo needs it, tell the user rather than guessing. Installation is user-specific (global npm vs. per-repo), and the dashboard URL and API key must be set up interactively via `hobble login`.
152
+
153
+ ## Maintaining This Document
154
+
155
+ When CLI feature work lands, update in the same change:
156
+ 1. The **Command Surface** table (new command, flag, argument, or default).
157
+ 2. **Composition** (any change to stdout/stderr behavior, JSON shapes, TTY handling, or exit codes — these are load-bearing for scripts).
158
+ 3. **Critical Rules / Common Mistakes** if the change creates a new way to leak a secret or a new footgun.
159
+ 4. The version marker at the top of this file (match `cli/bin/hobble.ts`).
160
+
161
+ Verify claims against the source, not memory: `cli/bin/hobble.ts` registers commands; each `cli/commands/*.ts` defines its flags; `cli/lib/output.ts` defines the stdout/stderr contract; `cli/lib/config.ts` and `cli/lib/api.ts` define auth and env-var overrides.
@@ -19,7 +19,7 @@ const program = new Command();
19
19
  program
20
20
  .name("hobble")
21
21
  .description("CLI for managing secrets via the secret-management-dash API")
22
- .version("3.0.0")
22
+ .version("3.2.0")
23
23
  .enablePositionalOptions()
24
24
  .option("-v, --verbose", "Show debug output for troubleshooting");
25
25
  program.hook("preAction", (thisCommand) => {
@@ -1,12 +1,21 @@
1
1
  import { Command } from "commander";
2
2
  import inquirer from "inquirer";
3
- import { api } from "../lib/api.js";
4
- import { saveProjectConfig, addToGitignore, isHobbleInGitignore, } from "../lib/project-config.js";
3
+ import { api, setApiUrlOverride } from "../lib/api.js";
4
+ import { ensureAuthenticated } from "../lib/auth-flow.js";
5
+ import { loadProjectConfig, saveProjectConfig, addToGitignore, isHobbleInGitignore, } from "../lib/project-config.js";
5
6
  import { success, error } from "../lib/output.js";
6
7
  export const initCommand = new Command("init")
7
- .description("Configure project and environment for this directory")
8
- .action(async () => {
8
+ .description("Configure project and environment for this directory (signs you in first if needed)")
9
+ .option("--url <url>", "Hobble instance URL — logs in against it and pins it in .hobble")
10
+ .action(async (opts) => {
9
11
  try {
12
+ // One-stop setup: authenticate (or re-authenticate) against the
13
+ // effective instance before touching the API.
14
+ const apiUrl = await ensureAuthenticated({ url: opts.url });
15
+ // Pin every API call in this run to the instance we just verified —
16
+ // .hobble doesn't exist yet to carry the override.
17
+ setApiUrlOverride(apiUrl);
18
+ const existing = await loadProjectConfig();
10
19
  // Fetch projects
11
20
  const projectData = (await api.get("/api/v1/projects"));
12
21
  if (projectData.projects.length === 0) {
@@ -42,9 +51,13 @@ export const initCommand = new Command("init")
42
51
  })),
43
52
  },
44
53
  ]);
54
+ // Pin the instance in .hobble when --url named one explicitly, and
55
+ // preserve a pin a previous init/switch already wrote.
56
+ const pinnedUrl = opts.url ? apiUrl : existing?.apiUrl;
45
57
  await saveProjectConfig({
46
58
  project: selectedProject.name,
47
59
  environment: envName,
60
+ ...(pinnedUrl ? { apiUrl: pinnedUrl } : {}),
48
61
  });
49
62
  // Offer to add .hobble to .gitignore — but only if it's not already there
50
63
  const alreadyIgnored = await isHobbleInGitignore();
@@ -61,7 +74,9 @@ export const initCommand = new Command("init")
61
74
  await addToGitignore();
62
75
  }
63
76
  }
64
- success(`Configured: ${selectedProject.name}/${envName}`);
77
+ success(pinnedUrl
78
+ ? `Configured: ${selectedProject.name}/${envName} @ ${pinnedUrl}`
79
+ : `Configured: ${selectedProject.name}/${envName}`);
65
80
  }
66
81
  catch (err) {
67
82
  error(err.message);
@@ -1,223 +1,16 @@
1
1
  import { Command } from "commander";
2
- import inquirer from "inquirer";
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
  });
@@ -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
- .action(async () => {
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
- if (!config) {
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: "Remove stored credentials?",
44
+ message: `Remove stored credentials for ${apiUrl}?`,
18
45
  default: false,
19
46
  },
20
47
  ]);
21
48
  if (!confirm)
22
49
  return;
23
- await deleteConfig();
24
- success("Logged out.");
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
  });
@@ -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
- await saveProjectConfig({ project: projectName, environment: envName });
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) {
@@ -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
- if (config?.auth.type === "session") {
35
- console.log(`Auth: session (${describeExpiry(config.auth.expiresAt)})`);
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 (config?.auth.type === "apiKey") {
44
+ else if (credential?.type === "apiKey") {
38
45
  console.log(`Auth: api key`);
39
46
  }
40
47
  }
package/dist/lib/api.d.ts CHANGED
@@ -2,6 +2,12 @@ export declare class ApiError extends Error {
2
2
  status: number;
3
3
  constructor(status: number, message: string);
4
4
  }
5
+ /**
6
+ * Pin the instance URL for the rest of this process. Used by `hobble init
7
+ * --url` so its API calls hit the instance it just authenticated against,
8
+ * before any .hobble file exists to record it.
9
+ */
10
+ export declare function setApiUrlOverride(url: string): void;
5
11
  export declare const api: {
6
12
  get: (path: string) => Promise<unknown>;
7
13
  post: (path: string, body: unknown) => Promise<unknown>;
package/dist/lib/api.js CHANGED
@@ -1,4 +1,5 @@
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;
@@ -8,8 +9,17 @@ export class ApiError extends Error {
8
9
  this.name = "ApiError";
9
10
  }
10
11
  }
11
- async function getHeaders() {
12
- const credential = await getAuthCredential();
12
+ let apiUrlOverride = null;
13
+ /**
14
+ * Pin the instance URL for the rest of this process. Used by `hobble init
15
+ * --url` so its API calls hit the instance it just authenticated against,
16
+ * before any .hobble file exists to record it.
17
+ */
18
+ export function setApiUrlOverride(url) {
19
+ apiUrlOverride = url;
20
+ }
21
+ async function getHeaders(baseUrl) {
22
+ const credential = await getAuthCredential(baseUrl);
13
23
  if (credential.type === "apiKey") {
14
24
  return {
15
25
  headers: {
@@ -29,13 +39,24 @@ async function getHeaders() {
29
39
  }
30
40
  let _baseUrlLogged = false;
31
41
  async function getBaseUrl() {
42
+ if (apiUrlOverride) {
43
+ if (!_baseUrlLogged) {
44
+ _baseUrlLogged = true;
45
+ debug(`Config: API URL pinned for this run (${apiUrlOverride})`);
46
+ }
47
+ return apiUrlOverride;
48
+ }
32
49
  const config = await loadConfig();
33
- const url = getApiUrl(config);
50
+ const projectConfig = await loadProjectConfig();
51
+ const url = getApiUrl(config, projectConfig?.apiUrl ?? null);
34
52
  if (!_baseUrlLogged) {
35
53
  _baseUrlLogged = true;
36
54
  if (process.env.HOBBLE_API_URL) {
37
55
  debug(`Config: API URL from HOBBLE_API_URL (${url})`);
38
56
  }
57
+ else if (projectConfig?.apiUrl) {
58
+ debug(`Config: API URL from .hobble (${url})`);
59
+ }
39
60
  else if (config?.apiUrl) {
40
61
  debug(`Config: API URL from ~/.config/hobble/config.json (${url})`);
41
62
  }
@@ -47,7 +68,7 @@ async function getBaseUrl() {
47
68
  }
48
69
  async function request(method, path, body) {
49
70
  const baseUrl = await getBaseUrl();
50
- const { headers, credentialType } = await getHeaders();
71
+ const { headers, credentialType } = await getHeaders(baseUrl);
51
72
  const fullUrl = `${baseUrl}${path}`;
52
73
  debug(`${method} ${fullUrl}`);
53
74
  let res;
@@ -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>;
@@ -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
+ }
@@ -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
- auth: AuthCredential;
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
- export declare function getAuthCredential(): Promise<AuthCredential>;
17
- export declare function getApiUrl(config: GlobalConfig | null): string;
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>;
@@ -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
- function isGlobalConfig(value) {
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 v = value;
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 (typeof parsed.apiKey !== "string" || typeof parsed.apiUrl !== "string") {
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
- return {
27
- apiUrl: parsed.apiUrl,
28
- auth: { type: "apiKey", token: parsed.apiKey },
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
- export async function getAuthCredential() {
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
- if (!config?.auth)
73
- throw new Error("Not logged in. Run `hobble login` first.");
74
- return config.auth;
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
- export function getApiUrl(config) {
77
- return process.env.HOBBLE_API_URL ?? config?.apiUrl ?? "http://localhost:3000";
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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hobble-env/cli",
3
- "version": "3.0.1",
3
+ "version": "3.3.0",
4
4
  "description": "CLI for managing secrets via hobble",
5
5
  "type": "module",
6
6
  "bin": {