@hobble-env/cli 3.0.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 +148 -0
- package/SKILL.md +91 -0
- package/dist/bin/hobble.d.ts +2 -0
- package/dist/bin/hobble.js +46 -0
- package/dist/commands/delete.d.ts +2 -0
- package/dist/commands/delete.js +43 -0
- package/dist/commands/env-pull.d.ts +2 -0
- package/dist/commands/env-pull.js +56 -0
- package/dist/commands/env-push.d.ts +2 -0
- package/dist/commands/env-push.js +135 -0
- package/dist/commands/get.d.ts +2 -0
- package/dist/commands/get.js +43 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +70 -0
- package/dist/commands/list.d.ts +4 -0
- package/dist/commands/list.js +52 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +223 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +25 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +40 -0
- package/dist/commands/set.d.ts +2 -0
- package/dist/commands/set.js +65 -0
- package/dist/commands/shell.d.ts +2 -0
- package/dist/commands/shell.js +88 -0
- package/dist/commands/switch.d.ts +2 -0
- package/dist/commands/switch.js +37 -0
- package/dist/commands/tags.d.ts +2 -0
- package/dist/commands/tags.js +33 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +45 -0
- package/dist/lib/api.d.ts +10 -0
- package/dist/lib/api.js +88 -0
- package/dist/lib/browser-login.d.ts +38 -0
- package/dist/lib/browser-login.js +88 -0
- package/dist/lib/config.d.ts +29 -0
- package/dist/lib/config.js +102 -0
- package/dist/lib/env-format.d.ts +6 -0
- package/dist/lib/env-format.js +35 -0
- package/dist/lib/output.d.ts +9 -0
- package/dist/lib/output.js +36 -0
- package/dist/lib/project-config.d.ts +21 -0
- package/dist/lib/project-config.js +55 -0
- package/dist/lib/shell-prompt.d.ts +31 -0
- package/dist/lib/shell-prompt.js +101 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Hobble CLI
|
|
2
|
+
|
|
3
|
+
A command-line tool for managing secrets stored in your secret management dashboard. Hobble lets you create, read, update, and delete secrets, and inject them into your development workflow as environment variables.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install globally from npm:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @hobble-env/cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This provides the `hobble` command.
|
|
14
|
+
|
|
15
|
+
### From source
|
|
16
|
+
|
|
17
|
+
From the project root:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd cli && npm install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run commands with:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx tsx cli/bin/hobble.ts <command>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or use the shortcut from the root:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm run hobble -- <command>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Getting Started
|
|
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
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
hobble login
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
You'll be prompted for your API key and the dashboard URL (defaults to `http://localhost:3000`).
|
|
48
|
+
|
|
49
|
+
### 3. Initialize a Project
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
hobble init
|
|
53
|
+
```
|
|
54
|
+
|
|
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:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
hobble switch my-project/staging
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
### Secrets
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
hobble list # List all secrets (names only, no values)
|
|
67
|
+
hobble list --json # List as JSON
|
|
68
|
+
|
|
69
|
+
hobble get DB_HOST # Print a secret's decrypted value
|
|
70
|
+
hobble get DB_HOST --json # Output as JSON
|
|
71
|
+
|
|
72
|
+
hobble set DB_HOST localhost # Create or update a secret
|
|
73
|
+
echo "s3cr3t" | hobble set DB_PASS - # Set from stdin (avoids shell history)
|
|
74
|
+
|
|
75
|
+
hobble delete DB_HOST # Delete a secret (with confirmation)
|
|
76
|
+
hobble delete DB_HOST -y # Skip confirmation
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Running Commands with Secrets
|
|
80
|
+
|
|
81
|
+
Fetch secrets from the dashboard and inject them as environment variables into a subprocess:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
hobble run npm run dev
|
|
85
|
+
hobble run -- node server.js --port 8080
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Secrets are injected in-memory only -- nothing is written to disk.
|
|
89
|
+
|
|
90
|
+
### Starting a Subshell with Secrets
|
|
91
|
+
|
|
92
|
+
Start an interactive subshell with the current project/environment's secrets injected as environment variables. Exit the subshell (type `exit` or Ctrl+D) to return to your original shell with its original environment intact.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# In a directory with a .hobble file
|
|
96
|
+
hobble shell
|
|
97
|
+
|
|
98
|
+
# Or specify project/env explicitly
|
|
99
|
+
hobble shell --project my-app --env production
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Inside the subshell, three extra env vars are set so scripts can detect the context:
|
|
103
|
+
|
|
104
|
+
- `HOBBLE_PROJECT` — project name
|
|
105
|
+
- `HOBBLE_ENVIRONMENT` — environment name
|
|
106
|
+
- `HOBBLE_SHELL` — `<project>/<environment>`
|
|
107
|
+
|
|
108
|
+
For bash and zsh, your prompt is prefixed with `(hobble:<project>/<environment>)` as a visual reminder. Other shells keep their default prompt; use `$HOBBLE_SHELL` to build your own prompt indicator if desired.
|
|
109
|
+
|
|
110
|
+
### Managing .env Files
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
hobble env pull # Write secrets to .env
|
|
114
|
+
hobble env pull --force # Overwrite existing .env
|
|
115
|
+
hobble env pull -o .env.local # Write to a custom path
|
|
116
|
+
|
|
117
|
+
hobble env push # Push .env contents to the dashboard
|
|
118
|
+
hobble env push -y # Skip confirmation
|
|
119
|
+
hobble env push -i .env.local # Push from a custom path
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Account
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
hobble whoami # Show current user and org info
|
|
126
|
+
hobble logout # Remove stored credentials
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
### Global Config
|
|
132
|
+
|
|
133
|
+
Stored at `~/.config/hobble/config.json` after `hobble login`. Contains your API key and dashboard URL.
|
|
134
|
+
|
|
135
|
+
Override with environment variables:
|
|
136
|
+
|
|
137
|
+
- `HOBBLE_API_KEY` -- override the stored API key
|
|
138
|
+
- `HOBBLE_API_URL` -- override the stored dashboard URL
|
|
139
|
+
|
|
140
|
+
### Project Config
|
|
141
|
+
|
|
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.
|
|
143
|
+
|
|
144
|
+
Override per-command with flags:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
hobble list --project my-app --env production
|
|
148
|
+
```
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: using-hobble-cli
|
|
3
|
+
description: Use when a repository contains a `.hobble` file, references the hobble CLI in scripts/docs, or you need to run or test code that depends on secrets managed by a hobble dashboard
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Using the hobble CLI
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
`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`.
|
|
11
|
+
|
|
12
|
+
## Detecting hobble in a Repo
|
|
13
|
+
|
|
14
|
+
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": "..." }`.
|
|
16
|
+
- `.gitignore` lists `.hobble`.
|
|
17
|
+
- Scripts, Makefiles, or docs invoke `hobble ...`.
|
|
18
|
+
- `package.json` has a script wrapping `hobble run`.
|
|
19
|
+
|
|
20
|
+
If none of these are present, the repo is not a hobble consumer — do not introduce `hobble` without asking.
|
|
21
|
+
|
|
22
|
+
## Critical Rules
|
|
23
|
+
|
|
24
|
+
1. **Never commit `.hobble`, `.env`, `.env.local`, or any file produced by `hobble env pull`.** Before running pull, confirm `.gitignore` covers the output path.
|
|
25
|
+
2. **Prefer `hobble run -- <cmd>` over `hobble env pull`.** `run` injects secrets into a single subprocess in memory. `pull` writes plaintext to disk and must be cleaned up. Only pull when a tool literally cannot read env vars.
|
|
26
|
+
3. **Never pass secret values as CLI arguments.** They leak into shell history and `ps` output. Use stdin:
|
|
27
|
+
```bash
|
|
28
|
+
printf '%s' "$value" | hobble set DB_PASS -
|
|
29
|
+
```
|
|
30
|
+
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.
|
|
31
|
+
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
|
+
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
|
+
|
|
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` |
|
|
47
|
+
|
|
48
|
+
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
|
+
|
|
50
|
+
## Context Resolution
|
|
51
|
+
|
|
52
|
+
Each data command needs a `project` and `environment`, resolved in order:
|
|
53
|
+
1. `--project` / `--env` flags on the command
|
|
54
|
+
2. `.hobble` in the current working directory
|
|
55
|
+
3. Error: `No project configured. Run \`hobble init\` or pass \`--project\` and \`--env\`.`
|
|
56
|
+
|
|
57
|
+
If you hit that error, stop and ask the user. Do not run `hobble init` on their behalf — it is interactive and binds the directory to a project.
|
|
58
|
+
|
|
59
|
+
## Authentication
|
|
60
|
+
|
|
61
|
+
- Stored config: `~/.config/hobble/config.json` (written by `hobble login`).
|
|
62
|
+
- 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.
|
|
64
|
+
|
|
65
|
+
## Common Mistakes
|
|
66
|
+
|
|
67
|
+
| Mistake | Correct approach |
|
|
68
|
+
|---------|------------------|
|
|
69
|
+
| `hobble set KEY "mysecret"` | `printf '%s' "$v" \| hobble set KEY -` — args leak to shell history |
|
|
70
|
+
| `hobble env pull` to "inspect" secrets | Use `hobble list` for keys; `hobble get KEY` piped to the consumer for one value |
|
|
71
|
+
| `git add -A` after `hobble env pull` | Verify `.gitignore`, then stage files by name |
|
|
72
|
+
| Running `hobble login` / `hobble init` to "set things up" | Ask the user — both are interactive and user-scoped |
|
|
73
|
+
| 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 |
|
|
75
|
+
|
|
76
|
+
## Running Tests That Need Secrets
|
|
77
|
+
|
|
78
|
+
Prefer:
|
|
79
|
+
```bash
|
|
80
|
+
hobble run -- npm test
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Avoid the pull-then-test pattern — it leaves a plaintext `.env` behind if the test fails:
|
|
84
|
+
```bash
|
|
85
|
+
# anti-pattern
|
|
86
|
+
hobble env pull && npm test
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## When hobble Is Missing
|
|
90
|
+
|
|
91
|
+
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`.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { setVerbose } from "../lib/output.js";
|
|
4
|
+
import { loginCommand } from "../commands/login.js";
|
|
5
|
+
import { logoutCommand } from "../commands/logout.js";
|
|
6
|
+
import { whoamiCommand } from "../commands/whoami.js";
|
|
7
|
+
import { initCommand } from "../commands/init.js";
|
|
8
|
+
import { switchCommand } from "../commands/switch.js";
|
|
9
|
+
import { listCommand } from "../commands/list.js";
|
|
10
|
+
import { getCommand } from "../commands/get.js";
|
|
11
|
+
import { setCommand } from "../commands/set.js";
|
|
12
|
+
import { tagsCommand } from "../commands/tags.js";
|
|
13
|
+
import { deleteCommand } from "../commands/delete.js";
|
|
14
|
+
import { runCommand } from "../commands/run.js";
|
|
15
|
+
import { shellCommand } from "../commands/shell.js";
|
|
16
|
+
import { envPullCommand } from "../commands/env-pull.js";
|
|
17
|
+
import { envPushCommand } from "../commands/env-push.js";
|
|
18
|
+
const program = new Command();
|
|
19
|
+
program
|
|
20
|
+
.name("hobble")
|
|
21
|
+
.description("CLI for managing secrets via the secret-management-dash API")
|
|
22
|
+
.version("3.0.0")
|
|
23
|
+
.enablePositionalOptions()
|
|
24
|
+
.option("-v, --verbose", "Show debug output for troubleshooting");
|
|
25
|
+
program.hook("preAction", (thisCommand) => {
|
|
26
|
+
const opts = thisCommand.optsWithGlobals();
|
|
27
|
+
if (opts.verbose)
|
|
28
|
+
setVerbose(true);
|
|
29
|
+
});
|
|
30
|
+
program.addCommand(loginCommand);
|
|
31
|
+
program.addCommand(logoutCommand);
|
|
32
|
+
program.addCommand(whoamiCommand);
|
|
33
|
+
program.addCommand(initCommand);
|
|
34
|
+
program.addCommand(switchCommand);
|
|
35
|
+
program.addCommand(listCommand);
|
|
36
|
+
program.addCommand(getCommand);
|
|
37
|
+
program.addCommand(setCommand);
|
|
38
|
+
program.addCommand(tagsCommand);
|
|
39
|
+
program.addCommand(deleteCommand);
|
|
40
|
+
program.addCommand(runCommand);
|
|
41
|
+
program.addCommand(shellCommand);
|
|
42
|
+
const envCommand = new Command("env").description("Manage .env files");
|
|
43
|
+
envCommand.addCommand(envPullCommand);
|
|
44
|
+
envCommand.addCommand(envPushCommand);
|
|
45
|
+
program.addCommand(envCommand);
|
|
46
|
+
program.parse();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { api } from "../lib/api.js";
|
|
4
|
+
import { resolveContext } from "../lib/project-config.js";
|
|
5
|
+
import { resolveProjectId } from "./list.js";
|
|
6
|
+
import { success, error } from "../lib/output.js";
|
|
7
|
+
export const deleteCommand = new Command("delete")
|
|
8
|
+
.description("Delete a secret")
|
|
9
|
+
.argument("<key>", "Secret key name")
|
|
10
|
+
.option("--project <name>", "Project name")
|
|
11
|
+
.option("--env <name>", "Environment name")
|
|
12
|
+
.option("-y, --yes", "Skip confirmation")
|
|
13
|
+
.action(async (key, opts) => {
|
|
14
|
+
try {
|
|
15
|
+
const ctx = await resolveContext(opts);
|
|
16
|
+
const projectId = await resolveProjectId(ctx.project);
|
|
17
|
+
// Find the secret by key name
|
|
18
|
+
const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
|
|
19
|
+
const match = listResponse.data.find((s) => s.key === key);
|
|
20
|
+
if (!match) {
|
|
21
|
+
error(`Secret "${key}" not found.`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
if (!opts.yes) {
|
|
25
|
+
const { confirm } = await inquirer.prompt([
|
|
26
|
+
{
|
|
27
|
+
type: "confirm",
|
|
28
|
+
name: "confirm",
|
|
29
|
+
message: `Delete secret "${key}"?`,
|
|
30
|
+
default: false,
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
if (!confirm)
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await api.delete(`/api/v1/secrets/${match.id}`);
|
|
37
|
+
success(`Deleted "${key}"`);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
error(err.message);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { writeFile, access } from "node:fs/promises";
|
|
3
|
+
import { api } from "../lib/api.js";
|
|
4
|
+
import { resolveContext } from "../lib/project-config.js";
|
|
5
|
+
import { resolveProjectId } from "./list.js";
|
|
6
|
+
import { success, warn, error } from "../lib/output.js";
|
|
7
|
+
import { formatEnvComment } from "../lib/env-format.js";
|
|
8
|
+
export const envPullCommand = new Command("pull")
|
|
9
|
+
.description("Write secrets to a .env file")
|
|
10
|
+
.option("--project <name>", "Project name")
|
|
11
|
+
.option("--env <name>", "Environment name")
|
|
12
|
+
.option("--force", "Overwrite existing .env file")
|
|
13
|
+
.option("-o, --output <path>", "Output file path", ".env")
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
try {
|
|
16
|
+
// Check if .env already exists
|
|
17
|
+
if (!opts.force) {
|
|
18
|
+
try {
|
|
19
|
+
await access(opts.output);
|
|
20
|
+
error(`${opts.output} already exists. Use --force to overwrite.`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// File doesn't exist, good
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const ctx = await resolveContext(opts);
|
|
28
|
+
const projectId = await resolveProjectId(ctx.project);
|
|
29
|
+
// Fetch all secrets
|
|
30
|
+
const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
|
|
31
|
+
const header = [
|
|
32
|
+
`# Generated by hobble from ${ctx.project}/${ctx.environment}`,
|
|
33
|
+
`# ${new Date().toISOString()}`,
|
|
34
|
+
];
|
|
35
|
+
const blocks = [];
|
|
36
|
+
for (const s of listResponse.data) {
|
|
37
|
+
const detailResponse = (await api.get(`/api/v1/secrets/${s.id}`));
|
|
38
|
+
const detail = detailResponse.data;
|
|
39
|
+
// Quote values that contain spaces or special characters
|
|
40
|
+
const needsQuotes = /[\s#=]/.test(detail.value);
|
|
41
|
+
const assignment = needsQuotes
|
|
42
|
+
? `${detail.key}="${detail.value}"`
|
|
43
|
+
: `${detail.key}=${detail.value}`;
|
|
44
|
+
const block = [...formatEnvComment(detail.comment), assignment];
|
|
45
|
+
blocks.push(block.join("\n"));
|
|
46
|
+
}
|
|
47
|
+
const body = [header.join("\n"), ...blocks].join("\n\n");
|
|
48
|
+
await writeFile(opts.output, body + "\n", "utf-8");
|
|
49
|
+
success(`Wrote ${listResponse.data.length} secrets to ${opts.output}`);
|
|
50
|
+
warn("Make sure this file is in your .gitignore!");
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
error(err.message);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { parse } from "dotenv";
|
|
4
|
+
import inquirer from "inquirer";
|
|
5
|
+
import { api } from "../lib/api.js";
|
|
6
|
+
import { resolveContext } from "../lib/project-config.js";
|
|
7
|
+
import { resolveProjectId } from "./list.js";
|
|
8
|
+
import { success, info, error } from "../lib/output.js";
|
|
9
|
+
const HOBBLE_BANNER = /^# Generated by hobble\b/;
|
|
10
|
+
/**
|
|
11
|
+
* Walk the raw .env source and associate each contiguous run of `# ...` lines
|
|
12
|
+
* (separated by no blank lines) with the next KEY=VALUE assignment. The banner
|
|
13
|
+
* comments emitted by `hobble pull` are stripped so they don't get re-pushed.
|
|
14
|
+
*/
|
|
15
|
+
function extractComments(raw) {
|
|
16
|
+
const lines = raw.split(/\r?\n/);
|
|
17
|
+
const buffer = [];
|
|
18
|
+
const result = {};
|
|
19
|
+
for (const rawLine of lines) {
|
|
20
|
+
const line = rawLine.trim();
|
|
21
|
+
if (line.startsWith("#")) {
|
|
22
|
+
if (HOBBLE_BANNER.test(line)) {
|
|
23
|
+
buffer.length = 0;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// strip leading "#" and a single optional space
|
|
27
|
+
buffer.push(line.replace(/^#\s?/, ""));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (line === "") {
|
|
31
|
+
buffer.length = 0;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
35
|
+
if (match && buffer.length > 0) {
|
|
36
|
+
result[match[1]] = buffer.join("\n");
|
|
37
|
+
}
|
|
38
|
+
buffer.length = 0;
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
export const envPushCommand = new Command("push")
|
|
43
|
+
.description("Push a .env file to the API")
|
|
44
|
+
.option("--project <name>", "Project name")
|
|
45
|
+
.option("--env <name>", "Environment name")
|
|
46
|
+
.option("-y, --yes", "Skip confirmation")
|
|
47
|
+
.option("-i, --input <path>", "Input file path", ".env")
|
|
48
|
+
.action(async (opts) => {
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readFile(opts.input, "utf-8").catch(() => {
|
|
51
|
+
error(`File not found: ${opts.input}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
54
|
+
const rawText = raw;
|
|
55
|
+
const parsed = parse(rawText);
|
|
56
|
+
const comments = extractComments(rawText);
|
|
57
|
+
const allKeys = Object.keys(parsed);
|
|
58
|
+
if (allKeys.length === 0) {
|
|
59
|
+
info("No secrets found in .env file.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const keysWithValues = allKeys.filter((k) => parsed[k] !== "");
|
|
63
|
+
const emptyKeys = allKeys.filter((k) => parsed[k] === "");
|
|
64
|
+
const ctx = await resolveContext(opts);
|
|
65
|
+
const projectId = await resolveProjectId(ctx.project);
|
|
66
|
+
// Fetch existing secrets to diff
|
|
67
|
+
const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
|
|
68
|
+
const existingByKey = new Map(listResponse.data.map((s) => [s.key, s]));
|
|
69
|
+
const toCreate = keysWithValues.filter((k) => !existingByKey.has(k));
|
|
70
|
+
const toUpdate = keysWithValues.filter((k) => existingByKey.has(k));
|
|
71
|
+
const toDelete = emptyKeys.filter((k) => existingByKey.has(k));
|
|
72
|
+
info(`${toCreate.length} to create, ${toUpdate.length} to update, ${toDelete.length} to delete`);
|
|
73
|
+
if (toCreate.length > 0) {
|
|
74
|
+
info(`Create: ${toCreate.join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
if (toUpdate.length > 0) {
|
|
77
|
+
info(`Update: ${toUpdate.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
if (toDelete.length > 0) {
|
|
80
|
+
info(`Delete: ${toDelete.join(", ")}`);
|
|
81
|
+
}
|
|
82
|
+
if (toCreate.length === 0 && toUpdate.length === 0 && toDelete.length === 0) {
|
|
83
|
+
info("No changes to apply.");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!opts.yes) {
|
|
87
|
+
const { confirm } = await inquirer.prompt([
|
|
88
|
+
{
|
|
89
|
+
type: "confirm",
|
|
90
|
+
name: "confirm",
|
|
91
|
+
message: "Apply these changes?",
|
|
92
|
+
default: false,
|
|
93
|
+
},
|
|
94
|
+
]);
|
|
95
|
+
if (!confirm)
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Apply changes
|
|
99
|
+
for (const key of toCreate) {
|
|
100
|
+
const body = {
|
|
101
|
+
projectId,
|
|
102
|
+
environment: ctx.environment,
|
|
103
|
+
key,
|
|
104
|
+
value: parsed[key],
|
|
105
|
+
};
|
|
106
|
+
if (comments[key] !== undefined)
|
|
107
|
+
body.comment = comments[key];
|
|
108
|
+
await api.post("/api/v1/secrets", body);
|
|
109
|
+
}
|
|
110
|
+
for (const key of toUpdate) {
|
|
111
|
+
const existing = existingByKey.get(key);
|
|
112
|
+
const incoming = comments[key];
|
|
113
|
+
const body = {
|
|
114
|
+
value: parsed[key],
|
|
115
|
+
};
|
|
116
|
+
// Only send comment when the file changes it. This prevents push from
|
|
117
|
+
// wiping a server-side comment when the user authored a .env without
|
|
118
|
+
// including comment lines.
|
|
119
|
+
if (incoming !== undefined && incoming !== (existing.comment ?? "")) {
|
|
120
|
+
body.comment = incoming;
|
|
121
|
+
}
|
|
122
|
+
await api.put(`/api/v1/secrets/${existing.id}`, body);
|
|
123
|
+
}
|
|
124
|
+
for (const key of toDelete) {
|
|
125
|
+
const existing = existingByKey.get(key);
|
|
126
|
+
await api.delete(`/api/v1/secrets/${existing.id}`);
|
|
127
|
+
}
|
|
128
|
+
const total = toCreate.length + toUpdate.length + toDelete.length;
|
|
129
|
+
success(`Pushed ${total} changes to ${ctx.project}/${ctx.environment}`);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
error(err.message);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { resolveContext } from "../lib/project-config.js";
|
|
4
|
+
import { resolveProjectId } from "./list.js";
|
|
5
|
+
import { error, isTTY } from "../lib/output.js";
|
|
6
|
+
export const getCommand = new Command("get")
|
|
7
|
+
.description("Get a decrypted secret value")
|
|
8
|
+
.argument("<key>", "Secret key name")
|
|
9
|
+
.option("--project <name>", "Project name")
|
|
10
|
+
.option("--env <name>", "Environment name")
|
|
11
|
+
.option("--json", "Output as JSON")
|
|
12
|
+
.action(async (key, opts) => {
|
|
13
|
+
try {
|
|
14
|
+
const ctx = await resolveContext(opts);
|
|
15
|
+
const projectId = await resolveProjectId(ctx.project);
|
|
16
|
+
// Find the secret by key name
|
|
17
|
+
const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
|
|
18
|
+
const match = listResponse.data.find((s) => s.key === key);
|
|
19
|
+
if (!match) {
|
|
20
|
+
error(`Secret "${key}" not found.`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
// Fetch decrypted value
|
|
24
|
+
const secretResponse = (await api.get(`/api/v1/secrets/${match.id}`));
|
|
25
|
+
const secret = secretResponse.data;
|
|
26
|
+
if (opts.json) {
|
|
27
|
+
process.stdout.write(JSON.stringify({ key: secret.key, value: secret.value, tags: (secret.tags ?? []).map((t) => t.name) }, null, 2) + "\n");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (isTTY()) {
|
|
31
|
+
console.log(`${secret.key}=${secret.value}`);
|
|
32
|
+
if (secret.tags && secret.tags.length)
|
|
33
|
+
console.log(`# tags: ${secret.tags.map((t) => t.name).join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
process.stdout.write(secret.value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
error(err.message);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { api } from "../lib/api.js";
|
|
4
|
+
import { saveProjectConfig, addToGitignore, isHobbleInGitignore, } from "../lib/project-config.js";
|
|
5
|
+
import { success, error } from "../lib/output.js";
|
|
6
|
+
export const initCommand = new Command("init")
|
|
7
|
+
.description("Configure project and environment for this directory")
|
|
8
|
+
.action(async () => {
|
|
9
|
+
try {
|
|
10
|
+
// Fetch projects
|
|
11
|
+
const projectData = (await api.get("/api/v1/projects"));
|
|
12
|
+
if (projectData.projects.length === 0) {
|
|
13
|
+
error("No projects found. Create one in the dashboard first.");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const { projectId } = await inquirer.prompt([
|
|
17
|
+
{
|
|
18
|
+
type: "list",
|
|
19
|
+
name: "projectId",
|
|
20
|
+
message: "Select a project:",
|
|
21
|
+
choices: projectData.projects.map((p) => ({
|
|
22
|
+
name: p.name,
|
|
23
|
+
value: p.id,
|
|
24
|
+
})),
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
const selectedProject = projectData.projects.find((p) => p.id === projectId);
|
|
28
|
+
// Fetch environments
|
|
29
|
+
const envData = (await api.get(`/api/v1/projects/${projectId}/environments`));
|
|
30
|
+
if (envData.environments.length === 0) {
|
|
31
|
+
error("No environments found. Create one in the dashboard first.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const { envName } = await inquirer.prompt([
|
|
35
|
+
{
|
|
36
|
+
type: "list",
|
|
37
|
+
name: "envName",
|
|
38
|
+
message: "Select an environment:",
|
|
39
|
+
choices: envData.environments.map((e) => ({
|
|
40
|
+
name: e.name,
|
|
41
|
+
value: e.name,
|
|
42
|
+
})),
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
await saveProjectConfig({
|
|
46
|
+
project: selectedProject.name,
|
|
47
|
+
environment: envName,
|
|
48
|
+
});
|
|
49
|
+
// Offer to add .hobble to .gitignore — but only if it's not already there
|
|
50
|
+
const alreadyIgnored = await isHobbleInGitignore();
|
|
51
|
+
if (!alreadyIgnored) {
|
|
52
|
+
const { addGitignore } = await inquirer.prompt([
|
|
53
|
+
{
|
|
54
|
+
type: "confirm",
|
|
55
|
+
name: "addGitignore",
|
|
56
|
+
message: "Add .hobble to .gitignore?",
|
|
57
|
+
default: true,
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
if (addGitignore) {
|
|
61
|
+
await addToGitignore();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
success(`Configured: ${selectedProject.name}/${envName}`);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
error(err.message);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
});
|