@hobble-env/cli 3.3.12 → 3.3.14

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/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: Use when a repository contains a `.hobble` file, references the hob
7
7
 
8
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
9
 
10
- <!-- documented-version: 3.3.10 — keep in sync with cli/bin/hobble.ts -->
10
+ <!-- documented-version: 3.3.13 — keep in sync with cli/bin/hobble.ts -->
11
11
 
12
12
  ## Overview
13
13
 
@@ -35,16 +35,16 @@ In this repo, the CLI runs from source: `npm run hobble -- <command>` (or `npx t
35
35
  ```
36
36
  4. **Never print, paste, or log the output of `hobble get`.** Do not echo it into chat, a commit message, or a log file. If you must move a value, pipe it directly to the destination.
37
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.
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.
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. Keys whose value and comment already match the server are not listed or counted, so the update list is exactly what changes.
39
39
 
40
- ## Command Surface (v3.3.10)
40
+ ## Command Surface (v3.3.13)
41
41
 
42
42
  Global options: `-v, --verbose` (debug output on stderr), `-V, --version`.
43
43
 
44
44
  | Command | Arguments / flags | Notes |
45
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. |
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. With no interactive terminal (`< /dev/null`, a piped CI step) the prompt cannot be answered: exits 1 with `Prompt cancelled (no interactive terminal).` |
47
+ | `logout` | `--all` | Removes stored credentials for the effective instance only; other instances stay logged in. `--all` removes every credential. Its confirmation prompt has the same non-TTY behaviour as `login`: exits 1 with `Prompt cancelled (no interactive terminal).` |
48
48
  | `whoami` | — | User, org, role, effective instance URL, and auth type (api key vs. session + expiry). |
49
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
50
  | `switch <project>/<env>` | — | Rewrites `.hobble` after validating both exist. Preserves an `"apiUrl"` pin. |
@@ -56,7 +56,7 @@ Global options: `-v, --verbose` (debug output on stderr), `-V, --version`.
56
56
  | `run <cmd...>` | `--project`, `--env` | Injects all secrets into a subprocess (in-memory, argv passed verbatim, no shell). Use `--` before the command so its flags aren't parsed by hobble. Exits with the child's exit code. |
57
57
  | `shell` | `--project`, `--env` | Interactive subshell with secrets injected. Sets `HOBBLE_PROJECT`, `HOBBLE_ENVIRONMENT`, `HOBBLE_SHELL=<project>/<env>`; prefixes bash/zsh prompts. Interactive — don't run unprompted. |
58
58
  | `env pull` | `--project`, `--env`, `--force`, `-o <path>` (default `.env`) | Writes plaintext secrets + comments to disk (values are bare when safe, otherwise dotenv-quoted). 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. |
59
+ | `env push` | `--project`, `--env`, `-y`, `-i <path>` (default `.env`) | Diffs file against server, prints create/update/delete lists, confirms unless `-y`. A key counts as an update only when its value or its comment actually differs — a push straight after an untouched `env pull` reports `0 to create, 0 to update, 0 to delete` and `No changes to apply.` **An empty value (`KEY=`) deletes that secret on the server.** Contiguous `# ...` lines above a key become its comment; comments are only sent when changed. |
60
60
  | `credential list` | `--org <slug\|id>`, `--json` | Org-scoped credentials (alias: `secret list`). Names/usernames/URLs, never values. |
61
61
  | `credential get <name>` | `--org`, `--field <value\|username\|url>`, `--json` | Decrypted credential (alias: `secret get`). Name match is case-insensitive. Non-TTY output is the raw field value only. |
62
62
  | `credential set <name> [value\|-]` | `--org`, `--username`, `--url`, `--comment <text>`, `--tag <name>` (repeatable) | Create-or-update (alias: `secret set`). `-` or omitted value reads stdin. `--tag` **replaces** all existing tags. |
@@ -112,7 +112,7 @@ JSON shapes:
112
112
  - `credential list --json`: array of `{ id, name, username, url, comment, createdAt, updatedAt, tags: [{ id, name, color }] }` — no values.
113
113
  - `credential get --json`: `{ name, username, url, value, comment, tags: [name, ...] }`.
114
114
 
115
- All commands exit `0` on success and `1` on any failure (not found, auth, network), with the reason on stderr.
115
+ All commands exit `0` on success and `1` on any failure (not found, auth, network), with the reason on stderr. A prompt that cannot be answered because stdin is not a terminal is a failure like any other: `✗ Prompt cancelled (no interactive terminal).` on stderr and exit `1`, never a library stack trace.
116
116
 
117
117
  ## Context Resolution
118
118
 
@@ -139,6 +139,7 @@ project root is.
139
139
  - Overrides via env vars: `HOBBLE_API_KEY`, `HOBBLE_API_URL`. Prefer these in CI.
140
140
  - 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.
141
141
  - `HOBBLE_ASSUME_TTY=1` makes `init` treat piped stdin as interactive (test harness escape hatch — do not use it to bypass Rule 5).
142
+ - An environment-scoped API token works with `--project`/`--env`: it can resolve its environment's parent project by name and list that project's environments, but any read or write outside the scoped environment(s) fails with a permission error. A project name the credential is not scoped to reports `not found, or your credential is not scoped to it`.
142
143
 
143
144
  ## Common Mistakes
144
145
 
@@ -20,7 +20,7 @@ const program = new Command();
20
20
  program
21
21
  .name("hobble")
22
22
  .description("CLI for managing secrets via the secret-management-dash API")
23
- .version("3.3.11")
23
+ .version("3.3.13")
24
24
  .enablePositionalOptions()
25
25
  .option("-v, --verbose", "Show debug output for troubleshooting");
26
26
  program.hook("preAction", (thisCommand) => {
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { parse } from "dotenv";
4
4
  import inquirer from "inquirer";
5
5
  import { api } from "../lib/api.js";
6
+ import { isSecretUnchanged } from "../lib/env-diff.js";
6
7
  import { resolveContext } from "../lib/project-config.js";
7
8
  import { resolveProjectId } from "./list.js";
8
9
  import { success, info, error } from "../lib/output.js";
@@ -67,8 +68,18 @@ export const envPushCommand = new Command("push")
67
68
  const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
68
69
  const existingByKey = new Map(listResponse.data.map((s) => [s.key, s]));
69
70
  const toCreate = keysWithValues.filter((k) => !existingByKey.has(k));
70
- const toUpdate = keysWithValues.filter((k) => existingByKey.has(k));
71
71
  const toDelete = emptyKeys.filter((k) => existingByKey.has(k));
72
+ // A key that already exists is only an update when its value or its
73
+ // comment actually differs (issue #69) — the value lives behind the
74
+ // per-secret detail endpoint, the same one `env pull` reads.
75
+ const toUpdate = [];
76
+ for (const key of keysWithValues.filter((k) => existingByKey.has(k))) {
77
+ const existing = existingByKey.get(key);
78
+ const detail = (await api.get(`/api/v1/secrets/${existing.id}`));
79
+ const unchanged = isSecretUnchanged({ value: parsed[key], comment: comments[key] }, { value: detail.data.value, comment: detail.data.comment });
80
+ if (!unchanged)
81
+ toUpdate.push(key);
82
+ }
72
83
  info(`${toCreate.length} to create, ${toUpdate.length} to update, ${toDelete.length} to delete`);
73
84
  if (toCreate.length > 0) {
74
85
  info(`Create: ${toCreate.join(", ")}`);
@@ -6,7 +6,7 @@ async function resolveProjectId(projectName) {
6
6
  const data = (await api.get("/api/v1/projects"));
7
7
  const proj = data.projects.find((p) => p.name === projectName);
8
8
  if (!proj) {
9
- throw new Error(`Project "${projectName}" not found. Run \`hobble init\` to select a project.`);
9
+ throw new Error(`Project "${projectName}" not found, or your credential is not scoped to it. Run \`hobble init\` to select a project.`);
10
10
  }
11
11
  return proj.id;
12
12
  }
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { loginWithApiKey, loginWithBrowser } from "../lib/auth-flow.js";
3
+ import { exitOnPromptFailure } from "../lib/prompt.js";
3
4
  export const loginCommand = new Command("login")
4
5
  .description("Authenticate with the secret management API")
5
6
  .option("--url <url>", "Instance URL to sign in to (skips the URL prompt)")
@@ -8,9 +9,14 @@ export const loginCommand = new Command("login")
8
9
  .action(async (opts) => {
9
10
  // Commander represents `--no-browser` as `opts.browser === false`
10
11
  const noBrowser = opts.browser === false;
11
- if (opts.apiKey) {
12
- await loginWithApiKey({ url: opts.url });
13
- return;
12
+ try {
13
+ if (opts.apiKey) {
14
+ await loginWithApiKey({ url: opts.url });
15
+ return;
16
+ }
17
+ await loginWithBrowser({ url: opts.url, noBrowser });
18
+ }
19
+ catch (err) {
20
+ exitOnPromptFailure(err);
14
21
  }
15
- await loginWithBrowser({ url: opts.url, noBrowser });
16
22
  });
@@ -3,10 +3,19 @@ import inquirer from "inquirer";
3
3
  import { deleteConfig, getApiUrl, loadConfig, removeCredential, } from "../lib/config.js";
4
4
  import { loadProjectConfig } from "../lib/project-config.js";
5
5
  import { success, info } from "../lib/output.js";
6
+ import { exitOnPromptFailure } from "../lib/prompt.js";
6
7
  export const logoutCommand = new Command("logout")
7
8
  .description("Remove stored credentials for the current instance")
8
9
  .option("--all", "Remove stored credentials for every instance")
9
10
  .action(async (opts) => {
11
+ try {
12
+ await runLogout(opts);
13
+ }
14
+ catch (err) {
15
+ exitOnPromptFailure(err);
16
+ }
17
+ });
18
+ async function runLogout(opts) {
10
19
  const config = await loadConfig();
11
20
  const instances = config ? Object.keys(config.credentials) : [];
12
21
  if (!config || instances.length === 0) {
@@ -53,4 +62,4 @@ export const logoutCommand = new Command("logout")
53
62
  if (remaining.length > 0) {
54
63
  info(`Still logged in to: ${remaining.join(", ")} — use \`hobble logout --all\` to remove everything.`);
55
64
  }
56
- });
65
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Diff helpers for `hobble env push` (issue #69).
3
+ *
4
+ * push used to classify every key that already exists on the server as an
5
+ * "update", so a push straight after an untouched pull reported the whole file
6
+ * as changed and the review step demanded by SKILL.md Critical Rule 6 told the
7
+ * user nothing.
8
+ */
9
+ /** A key's value and (optional) comment as they exist locally or on the server. */
10
+ export interface SecretDiffSide {
11
+ value: string;
12
+ /** `undefined` on the local side means the .env carried no comment lines. */
13
+ comment?: string | null;
14
+ }
15
+ /**
16
+ * Report whether the local key is byte-identical to the server's copy, and so
17
+ * must not be listed or counted as an update.
18
+ *
19
+ * A local `comment` of `undefined` is not a change: a .env authored without
20
+ * comment lines leaves the server-side comment alone (push only sends a
21
+ * comment when the file changes it), so only the value can differ.
22
+ */
23
+ export declare function isSecretUnchanged(local: SecretDiffSide, remote: SecretDiffSide): boolean;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Diff helpers for `hobble env push` (issue #69).
3
+ *
4
+ * push used to classify every key that already exists on the server as an
5
+ * "update", so a push straight after an untouched pull reported the whole file
6
+ * as changed and the review step demanded by SKILL.md Critical Rule 6 told the
7
+ * user nothing.
8
+ */
9
+ /**
10
+ * Report whether the local key is byte-identical to the server's copy, and so
11
+ * must not be listed or counted as an update.
12
+ *
13
+ * A local `comment` of `undefined` is not a change: a .env authored without
14
+ * comment lines leaves the server-side comment alone (push only sends a
15
+ * comment when the file changes it), so only the value can differ.
16
+ */
17
+ export function isSecretUnchanged(local, remote) {
18
+ if (local.value !== remote.value)
19
+ return false;
20
+ if (local.comment === undefined)
21
+ return true;
22
+ return (local.comment ?? "") === (remote.comment ?? "");
23
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Report a failed command and exit 1 (issue #69).
3
+ *
4
+ * inquirer rejects with its internal `ExitPromptError` when stdin closes —
5
+ * `hobble login < /dev/null`, a piped CI step, Ctrl-C. Left unhandled that
6
+ * printed a raw stack trace and still exited 0, contradicting the exit-code
7
+ * contract in `cli/SKILL.md`. The library's wording ("User force closed the
8
+ * prompt with 0 null") is not something a user can act on, so it is replaced.
9
+ */
10
+ export declare function exitOnPromptFailure(err: unknown): never;
@@ -0,0 +1,19 @@
1
+ import { error } from "./output.js";
2
+ /**
3
+ * Report a failed command and exit 1 (issue #69).
4
+ *
5
+ * inquirer rejects with its internal `ExitPromptError` when stdin closes —
6
+ * `hobble login < /dev/null`, a piped CI step, Ctrl-C. Left unhandled that
7
+ * printed a raw stack trace and still exited 0, contradicting the exit-code
8
+ * contract in `cli/SKILL.md`. The library's wording ("User force closed the
9
+ * prompt with 0 null") is not something a user can act on, so it is replaced.
10
+ */
11
+ export function exitOnPromptFailure(err) {
12
+ const cause = err;
13
+ const cancelled = cause?.name === "ExitPromptError" ||
14
+ /force closed the prompt/i.test(cause?.message ?? "");
15
+ error(cancelled
16
+ ? "Prompt cancelled (no interactive terminal)."
17
+ : (cause?.message ?? String(err)));
18
+ process.exit(1);
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hobble-env/cli",
3
- "version": "3.3.12",
3
+ "version": "3.3.14",
4
4
  "description": "CLI for managing secrets via hobble",
5
5
  "type": "module",
6
6
  "repository": {