Skip to content

Commit 71780f1

Browse files
ranaroussiclaude
andcommitted
feat: accept stored key names in envapor migrate
- migrate OLDKEY NEWKEY now resolves each argument as a stored key name first, falling back to a PEM file path (./ prefix forces a path) - Directories and unknown names produce clear errors pointing at 'envapor keys' instead of a raw read failure - Add resolveKeyArg tests covering names, paths, ambiguity, and dirs 🤖 Generated with Claude Code Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c16992 commit 71780f1

6 files changed

Lines changed: 103 additions & 9 deletions

File tree

‎.env‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SAMPLE_KEY=ENC[v2:NPrnvEanwQu9mYtgFWm7zWe0W7MkILJ+GZ0vdL1QWCrST4sbk7KAcNrFgTDN6dbm]
1+
SAMPLE_KEY=ENC[v2:abDKaSL0zjJaZJmJSrTRu2NJttsM2DKt41YRbeKZaumGg0cE8oyrPVzIywITyQS6]
22
PUBLIC_KEY=hello-world # PUBLIC

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Parsing **fails closed**: a value is left in plaintext *only* on an unambiguous
9393
| `envapor init NAME` or `--pem PATH` | Configure filters, hook, `.gitattributes`, and map the repo to a key (by stored name or key file) |
9494
| `envapor doctor` | Diagnose the setup (filters, hook, mapping, coverage, crypto round-trip) |
9595
| `envapor status` | Show the mapping and per-file encryption state |
96-
| `envapor migrate OLDPEM NEWPEM` | Re-encrypt managed values from one key to another |
96+
| `envapor migrate OLDKEY NEWKEY` | Re-encrypt managed values from one key to another (names or PEM paths) |
9797
| `envapor encrypt` / `decrypt` | Manually transform managed files (rarely needed) |
9898

9999
## How it works

‎docs/user-guide.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,9 @@ Runs a full health check and reports on:
274274

275275
Run this first whenever something looks off.
276276

277-
### `envapor migrate OLDPEM NEWPEM`
277+
### `envapor migrate OLDKEY NEWKEY`
278278

279-
Re-encrypts every managed value from the old key to a new one. Both arguments are paths to PEM key files. Used when a teammate leaves or a key is compromised. See [Rotating keys](#rotating-keys) for scope and limits.
279+
Re-encrypts every managed value from the old key to a new one. Each argument is either the name of a stored key (as shown by `envapor keys`) or a path to a PEM key file; when a name matches both, the stored key wins (prefix with `./` to force a file path). Used when a teammate leaves or a key is compromised. See [Rotating keys](#rotating-keys) for scope and limits.
280280

281281
### `envapor status`
282282

@@ -356,10 +356,10 @@ The action accepts two optional inputs:
356356
When a teammate leaves or a key is compromised:
357357

358358
```bash
359-
envapor migrate OLDPEM NEWPEM
359+
envapor migrate OLDKEY NEWKEY
360360
```
361361

362-
Both arguments are paths to PEM key files. This re-encrypts the current working tree and all future commits under the new key.
362+
Each argument is a stored key name (see `envapor keys`) or a path to a PEM key file. This re-encrypts the current working tree and all future commits under the new key.
363363

364364
**Important scope and limits:**
365365

‎src/internal/cmd/common.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"errors"
5+
"fmt"
56
"os"
67
"path/filepath"
78

@@ -56,6 +57,26 @@ func selfPath() (string, error) {
5657
return p, nil
5758
}
5859

60+
// resolveKeyArg interprets a user-supplied key argument as either the name of
61+
// a stored key or a path to a PEM file, returning the file path to load. A
62+
// stored key wins when both interpretations exist; prefix a path with ./ to
63+
// force the file interpretation.
64+
func resolveKeyArg(arg string) (string, error) {
65+
if path, err := config.KeyPath(arg); err == nil {
66+
if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
67+
return path, nil
68+
}
69+
}
70+
info, err := os.Stat(arg)
71+
if err != nil {
72+
return "", fmt.Errorf("%q is neither a stored key (see 'envapor keys') nor a PEM file", arg)
73+
}
74+
if info.IsDir() {
75+
return "", fmt.Errorf("%q is a directory; pass a stored key name (see 'envapor keys') or a PEM file", arg)
76+
}
77+
return arg, nil
78+
}
79+
5980
// keyNameFromPath derives a key name from a PEM file path, dropping a trailing
6081
// ".pem" extension.
6182
func keyNameFromPath(path string) string {

‎src/internal/cmd/common_test.go‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/automazeio/envapor/internal/config"
9+
"github.com/automazeio/envapor/internal/crypto"
10+
)
11+
12+
func TestResolveKeyArg(t *testing.T) {
13+
root := t.TempDir()
14+
t.Chdir(root)
15+
t.Setenv("ENVAPOR_HOME", filepath.Join(root, "config"))
16+
key, err := crypto.Generate()
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
if _, err := config.WriteKey("team", key.MarshalPEM()); err != nil {
21+
t.Fatal(err)
22+
}
23+
24+
stored, err := config.KeyPath("team")
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
if got, err := resolveKeyArg("team"); err != nil || got != stored {
29+
t.Fatalf("resolveKeyArg(name) = %q, %v; want %q", got, err, stored)
30+
}
31+
32+
pem := filepath.Join(root, "other.pem")
33+
if err := os.WriteFile(pem, key.MarshalPEM(), 0o600); err != nil {
34+
t.Fatal(err)
35+
}
36+
if got, err := resolveKeyArg(pem); err != nil || got != pem {
37+
t.Fatalf("resolveKeyArg(path) = %q, %v; want %q", got, err, pem)
38+
}
39+
40+
// A local file with the same name as a stored key: the stored key wins,
41+
// and the ./ prefix forces the file interpretation.
42+
local := filepath.Join(root, "team")
43+
if err := os.WriteFile(local, key.MarshalPEM(), 0o600); err != nil {
44+
t.Fatal(err)
45+
}
46+
if got, _ := resolveKeyArg("team"); got != stored {
47+
t.Fatalf("resolveKeyArg(ambiguous) = %q, want stored key %q", got, stored)
48+
}
49+
if got, err := resolveKeyArg("./team"); err != nil || got != "./team" {
50+
t.Fatalf("resolveKeyArg(./team) = %q, %v; want ./team", got, err)
51+
}
52+
53+
if err := os.Mkdir(filepath.Join(root, "demo"), 0o700); err != nil {
54+
t.Fatal(err)
55+
}
56+
if _, err := resolveKeyArg("demo"); err == nil {
57+
t.Fatal("resolveKeyArg accepted a directory")
58+
}
59+
60+
if _, err := resolveKeyArg("no-such-key"); err == nil {
61+
t.Fatal("resolveKeyArg accepted a nonexistent argument")
62+
}
63+
}

‎src/internal/cmd/migrate.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import (
1414
)
1515

1616
var migrateCmd = &cobra.Command{
17-
Use: "migrate OLDPEM NEWPEM",
17+
Use: "migrate OLDKEY NEWKEY",
1818
Short: "Re-encrypt managed values from an old key to a new one",
19-
Long: "Re-encrypts the working tree and future commits from OLDPEM to NEWPEM.\n" +
19+
Long: "Re-encrypts the working tree and future commits from OLDKEY to NEWKEY.\n" +
20+
"Each argument is either the name of a stored key (see 'envapor keys') or\n" +
21+
"the path to a PEM key file; a stored key wins when both exist.\n" +
2022
"It does not rewrite Git history: past commits stay encrypted under the old\n" +
2123
"key, so migration rotates the key, not the secrets themselves. After a\n" +
2224
"compromise, also rotate the affected secrets at their source.",
@@ -33,10 +35,18 @@ type migrationFile struct {
3335
mode os.FileMode
3436
}
3537

36-
func runMigrate(oldPEM, newPEM string) (err error) {
38+
func runMigrate(oldArg, newArg string) (err error) {
3739
if !gitutil.InsideRepo() {
3840
return fmt.Errorf("not a git repository")
3941
}
42+
oldPEM, err := resolveKeyArg(oldArg)
43+
if err != nil {
44+
return fmt.Errorf("old key: %w", err)
45+
}
46+
newPEM, err := resolveKeyArg(newArg)
47+
if err != nil {
48+
return fmt.Errorf("new key: %w", err)
49+
}
4050
oldKey, err := loadPEMFile(oldPEM)
4151
if err != nil {
4252
return fmt.Errorf("old key: %w", err)

0 commit comments

Comments
 (0)