feat: share env files as encrypted links #54

Merged
keb merged 25 commits from share-files into main 2026-10-13 14:55:22 +02:00
13 changed files with 147 additions and 62 deletions
Showing only changes of commit bc691d786a - Show all commits

No files matched your search

+1 -1
View File
@@ -119,7 +119,7 @@ func run() error {
{{secrets/scope/key}} {{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] share [--key <ref>] <env file>... lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted print a link to env files of a repo, encrypted
with a secret's value (default: share.key) with a secret's value (default: share.key)
lazysecrets [flags] keybindings print every action and its keys (with your lazysecrets [flags] keybindings print every action and its keys (with your
+3 -3
View File
@@ -21,12 +21,12 @@ import (
// lazysecrets share .env apps/api/.env // lazysecrets share .env apps/api/.env
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, args []string, out io.Writer) error { func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, args []string, out io.Writer) error {
fs := flag.NewFlagSet("share", flag.ContinueOnError) fs := flag.NewFlagSet("share", flag.ContinueOnError)
keyLink := fs.String("key", "", "lazysecrets:// link to the secret to encrypt with (default: share.key)") keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if fs.NArg() == 0 { if fs.NArg() == 0 {
return errors.New("usage: lazysecrets share [--key <link>] <env files>") return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
} }
var root, id string var root, id string
@@ -72,7 +72,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
return err return err
} }
if !ok { if !ok {
return errors.New("which secret to encrypt with? --key <link>, or share.key in the repo's .lazysecrets.yaml or the config") return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config")
} }
v, version, err := readKey(ctx, providers, st, key) v, version, err := readKey(ctx, providers, st, key)
if err != nil { if err != nil {
+1 -1
View File
@@ -36,7 +36,7 @@ providers:
include: ["secrets/**"] include: ["secrets/**"]
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
share: # sharing env files as encrypted links share: # sharing env files as encrypted links
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share # for repos naming none key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
``` ```
More, each on its own page: [repos](repos.md) (`providers.repos`), More, each on its own page: [repos](repos.md) (`providers.repos`),
+9 -4
View File
@@ -49,15 +49,20 @@ az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl
``` ```
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
uses it) or your config (for repos that don't name one), or pick one when uses it) or your config (for repos that don't name one), by its
sharing: `lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
reference:
```yaml ```yaml
share: share:
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share key: https://kv-team.vault.azure.net/secrets/env-share
``` ```
On the command line, `lazysecrets share [--key <link>] <env file>...` With neither, sharing asks which secret, in [Go to](keys.md#go-to): it
lists only secrets, and `enter` picks one (pasting a reference picks it
too).
On the command line, `lazysecrets share [--key <secret>] <env file>...`
prints the link. prints the link.
Opening a share link says which repo it's from, how many files and which Opening a share link says which repo it's from, how many files and which
+1 -1
View File
@@ -60,7 +60,7 @@ A repo's `.lazysecrets.yaml` can also name the secret its env files are
```yaml ```yaml
share: share:
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
``` ```
## Decrypting ## Decrypting
+4 -3
View File
@@ -86,8 +86,9 @@ type LinkHandler struct {
} }
type Share struct { type Share struct {
// Key is the link to the secret whose value encrypts shared files, // Key is the secret whose value encrypts shared files (its
// for repos whose .lazysecrets.yaml doesn't name one. // lazysecrets:// link, Key Vault URL or reference), for repos whose
// .lazysecrets.yaml doesn't name one.
Key string `yaml:"key"` Key string `yaml:"key"`
} }
@@ -232,7 +233,7 @@ func (c Config) validate(path string) error {
} }
if k := c.Share.Key; k != "" { if k := c.Share.Key; k != "" {
if r, err := ref.Parse(k); err != nil || r.IsShare() || r.IsSource() || r.IsContainer() { if r, err := ref.Parse(k); err != nil || r.IsShare() || r.IsSource() || r.IsContainer() {
return fmt.Errorf("%s: share.key: %q is not a lazysecrets:// link to a secret", path, k) return fmt.Errorf("%s: share.key: %q is not a secret's lazysecrets:// link, Key Vault URL or reference", path, k)
} }
} }
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
+11 -7
View File
@@ -87,8 +87,8 @@ type file struct {
// Seal encrypts files with key, the value of h.Key, into a share link. // Seal encrypts files with key, the value of h.Key, into a share link.
func Seal(h Header, key []byte, files []File) (ref.Ref, error) { func Seal(h Header, key []byte, files []File) (ref.Ref, error) {
if h.Key.IsShare() || h.Key.IsSource() || h.Key.IsContainer() { if _, err := ParseKey(h.Key.String()); err != nil {
return ref.Ref{}, errors.New("the key must be a secret's link") return ref.Ref{}, fmt.Errorf("the key: %w", err)
} }
if len(key) < MinKey { if len(key) < MinKey {
return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey) return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey)
@@ -248,14 +248,18 @@ func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
return key, true, nil return key, true, nil
} }
// ParseKey reads a key's link: a link to a secret. // ParseKey reads which secret is the key: its lazysecrets:// link, Key
func ParseKey(link string) (ref.Ref, error) { // Vault URL or @Microsoft.KeyVault(...) reference.
key, err := ref.Parse(link) func ParseKey(s string) (ref.Ref, error) {
key, err := ref.Parse(s)
if err != nil { if err != nil {
return ref.Ref{}, err return ref.Ref{}, err
} }
if key.IsShare() || key.IsSource() || key.IsContainer() { switch {
return ref.Ref{}, fmt.Errorf("%s isn't a secret's link", link) case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret):
return ref.Ref{}, fmt.Errorf("%s isn't a secret", s)
case key.Kind == provider.KindDatabricks && key.Host == "":
return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s)
} }
return key, nil return key, nil
} }
+24 -1
View File
@@ -63,7 +63,7 @@ func TestSealRefuses(t *testing.T) {
want string want string
}{ }{
"short key": {Header{Key: key}, []byte("short"), ok, "at least 16"}, "short key": {Header{Key: key}, []byte("short"), ok, "at least 16"},
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "secret's link"}, "vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
"no files": {Header{Key: key}, pass, nil, "no files"}, "no files": {Header{Key: key}, pass, nil, "no files"},
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"}, "not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"}, "escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
@@ -168,6 +168,29 @@ func TestPlanSymlinks(t *testing.T) {
} }
} }
func TestParseKey(t *testing.T) {
for _, s := range []string{
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share",
"https://kv-team.vault.azure.net/secrets/env-share",
"@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)",
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share",
} {
if k, err := ParseKey(s); err != nil || k.Name != "env-share" {
t.Errorf("%s: %+v, %v", s, k, err)
}
}
for s, want := range map[string]string{
"{{secrets/team/env-share}}": "names no workspace",
"https://kv-team.vault.azure.net/keys/sops": "isn't a secret",
"https://kv-team.vault.azure.net/": "isn't a secret",
"lazysecrets://azure/subscriptions/0000-1111": "isn't a secret",
} {
if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", s, err, want)
}
}
}
func TestDefaultKey(t *testing.T) { func TestDefaultKey(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share" general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
+1 -3
View File
@@ -273,9 +273,7 @@ type Model struct {
linked []provider.Container // vaults opened from links, this session linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup open *ref.Ref // reference to open at startup
// shareKey is the config's share.key; lastShareKey the key last shareKey string // the config's share.key
// typed when sharing, offered next time.
shareKey, lastShareKey string
// A lazysecrets:// link found on the clipboard, offered in the footer. // A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again dismissedLink string // the last link offered: not offered again
+3
View File
@@ -818,6 +818,9 @@ func (m *Model) openText(s string) tea.Cmd {
// handlePaste opens a pasted reference, unless a text input has focus. // handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if m.search != nil && m.search.pick != nil {
return m.pickPasted(msg.Content) // a reference to the secret
}
if p := m.prompt; p != nil && p.input != nil { if p := m.prompt; p != nil && p.input != nil {
in, cmd := p.input.Update(msg) in, cmd := p.input.Update(msg)
p.input = &in p.input = &in
+42 -2
View File
@@ -14,6 +14,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/match" "git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -52,6 +53,12 @@ type searchView struct {
res int // cursor in shown res int // cursor in shown
here string // the scope of the source Go to was opened from, if any here string // the scope of the source Go to was opened from, if any
// pick, if set, makes Go to a picker of secrets, for title: it lists
// secrets only, and enter gives the selected one's link to pick
// instead of going there.
pick func(m *Model, r ref.Ref) tea.Cmd
pickTitle, pickNote string
// loaded are the scopes a search has loaded since the popup opened: // loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for // typing loads the scope once, so lists that fail aren't asked for
// again on every key. // again on every key.
@@ -267,6 +274,9 @@ func (v *searchView) match() []option {
} }
} }
} }
if v.pick != nil {
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
}
q := v.input.Value() q := v.input.Value()
if q == "" { if q == "" {
return slices.Clone(cand) return slices.Clone(cand)
@@ -423,9 +433,15 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil return nil
case key.Matches(msg, k.sel): case key.Matches(msg, k.sel):
switch { switch {
case v.results && v.res < len(v.shown) && v.pick != nil:
r, _ := pickable(v.shown[v.res])
m.search = nil
return v.pick(m, r)
case v.results && v.res < len(v.shown): case v.results && v.res < len(v.shown):
m.search = nil m.search = nil
return v.shown[v.res].run(m) return v.shown[v.res].run(m)
case !v.results && v.pick != nil:
v.results = true // a picker only narrows to the scope
case !v.results && v.onSource(): case !v.results && v.onSource():
m.search = nil m.search = nil
return v.scope().run(m) return v.scope().run(m)
@@ -745,6 +761,9 @@ func (m Model) searchViewBox() string {
} else { } else {
body = append(body, "") // the same height on every tab body = append(body, "") // the same height on every tab
} }
if v.pick != nil && v.pickNote != "" {
body = append(body, " "+m.st.Muted.Render(v.pickNote))
}
km := m.keys km := m.keys
var foot string var foot string
switch { switch {
@@ -753,17 +772,20 @@ func (m Model) searchViewBox() string {
if v.tab == tabSearch { if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load")) hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
} }
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...) foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...)
default: default:
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}} hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
if v.tab == tabSearch { if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all")) hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
} }
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...) foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...)
} }
// Tabs in the title, the current one underlined, like the panes. // Tabs in the title, the current one underlined, like the panes.
ts := panes.TitleStyle(m.st, focused) ts := panes.TitleStyle(m.st, focused)
head := ts.Render(goToTitle + " ") head := ts.Render(goToTitle + " ")
if v.pick != nil {
head = ts.Render(v.pickTitle + " ")
}
for i, name := range tabNames { for i, name := range tabNames {
if i > 0 { if i > 0 {
head += ts.Render(" - ") head += ts.Render(" - ")
@@ -776,3 +798,21 @@ func (m Model) searchViewBox() string {
} }
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
} }
// selectLabel names what enter does with the selected entry.
func (v *searchView) selectLabel() string {
if v.pick != nil {
return "pick"
}
return "go"
}
// pickable is the link of a Go to entry that a picker can pick: a
// secret (not a key or certificate).
func pickable(o option) (ref.Ref, bool) {
r, err := ref.Parse(o.star)
if err != nil || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
return ref.Ref{}, false
}
return r, true
}
+20 -26
View File
@@ -102,7 +102,7 @@ func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
case msg.err != nil: case msg.err != nil:
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
case msg.key == (ref.Ref{}): case msg.key == (ref.Ref{}):
return m.askShareKey(msg.sharing) return m.pickShareKey(msg.sharing)
default: default:
m.confirmShare(msg.sharing) m.confirmShare(msg.sharing)
} }
@@ -118,40 +118,34 @@ func (m *Model) confirmShare(s sharing) {
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key)) lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
m.showMenu("Share as an encrypted link", lines, []option{ m.showMenu("Share as an encrypted link", lines, []option{
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }}, {key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }},
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.askShareKey(s) }}, {key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
}) })
} }
// askShareKey asks for the link to the secret to encrypt with. // pickShareKey asks which secret to encrypt with, in Go to.
func (m *Model) askShareKey(s sharing) tea.Cmd { func (m *Model) pickShareKey(s sharing) tea.Cmd {
in := textinput.New() cmd := m.openGoTo(tabSearch, true)
in.Prompt = "key: " m.search.pick = func(m *Model, key ref.Ref) tea.Cmd {
in.Placeholder = "lazysecrets:// link to a secret" s.key = key
in.SetWidth(max(20, min(m.w-12, 64))) m.confirmShare(s)
in.SetValue(m.lastShareKey) return nil
if s.key != (ref.Ref{}) {
in.SetValue(s.key.String())
} }
in.CursorEnd() m.search.pickTitle = "Encrypt with which secret?"
m.prompt = &modal{ m.search.pickNote = "Whoever can read it can open the link. share.key sets a default."
title: "Encrypt with which secret?", input: &in, m.fillSearch(false)
lines: []string{ return tea.Batch(cmd, m.searchLoads())
m.st.Muted.Render("Whoever can read it can open the link. s on a secret copies its link."), }
m.st.Muted.Render("share.key in the config or the repo's .lazysecrets.yaml sets one."),
}, // pickPasted picks a pasted reference to a secret, while picking one.
submit: func(m *Model, text string) tea.Cmd { func (m *Model) pickPasted(text string) tea.Cmd {
key, err := share.ParseKey(strings.TrimSpace(text)) key, err := share.ParseKey(strings.TrimSpace(text))
if err != nil { if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true} m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil return nil
} }
m.lastShareKey = key.String() pick := m.search.pick
s.key = key m.search = nil
m.confirmShare(s) return pick(m, key)
return nil
},
}
return m.prompt.input.Focus()
} }
// seal reads the key and encrypts the files with it. // seal reads the key and encrypts the files with it.
+27 -10
View File
@@ -18,9 +18,6 @@ import (
// which take about a second on purpose. // which take about a second on purpose.
const scryptWait = 20 * time.Second const scryptWait = 20 * time.Second
// shareKeyLink is a demo secret, long enough to be a key.
const shareKeyLink = "lazysecrets://azure/" + kv + "/secrets/db-password"
// cloneWith makes a clone of git.example.com/team/app holding files. // cloneWith makes a clone of git.example.com/team/app holding files.
func cloneWith(t *testing.T, files map[string]string) string { func cloneWith(t *testing.T, files map[string]string) string {
t.Helper() t.Helper()
@@ -35,7 +32,7 @@ func cloneWith(t *testing.T, files map[string]string) string {
// shareFrom shares a repo's env files from lazysecrets started in it, // shareFrom shares a repo's env files from lazysecrets started in it,
// returning the link copied. // returning the link copied.
func shareFrom(t *testing.T, root string) string { func shareFrom(t *testing.T, root string, paste bool) string {
t.Helper() t.Helper()
clip := &fakeClipboard{} clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root})) d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
@@ -44,12 +41,32 @@ func shareFrom(t *testing.T, root string) string {
t.Fatalf("selected %v, want the repo", src.ID) t.Fatalf("selected %v, want the repo", src.ID)
} }
d.keys("S", "f") d.keys("S", "f")
// No share.key set: it asks which secret. // No share.key set: it asks which secret, in Go to, which lists
if d.m.prompt == nil || d.m.prompt.input == nil { // only secrets.
t.Fatalf("no key prompt: %s", d.m.flash.text) v := d.m.search
if v == nil || v.pick == nil {
t.Fatalf("no secret picker: %s", d.m.flash.text)
}
if paste {
// A reference pasted is picked as it is.
d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"})
} else {
for _, r := range "app-prod db-password" {
d.keys(string(r))
}
if len(v.shown) == 0 {
t.Fatalf("no secrets to pick:\n%s", d.screen())
}
for _, o := range v.shown {
if _, ok := pickable(o); !ok {
t.Errorf("not a secret: %s", o.label)
}
}
if !strings.Contains(d.screen(), "Encrypt with which secret?") {
t.Errorf("not titled as a picker:\n%s", d.screen())
} }
d.send(tea.PasteMsg{Content: shareKeyLink})
d.keys("enter") d.keys("enter")
}
if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") { if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") {
t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen()) t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen())
} }
@@ -67,7 +84,7 @@ func TestShareFilesRoundTrip(t *testing.T) {
link := shareFrom(t, cloneWith(t, map[string]string{ link := shareFrom(t, cloneWith(t, map[string]string{
".env": "DB_PASSWORD=hunter2\n", ".env": "DB_PASSWORD=hunter2\n",
"prod.env": "TOKEN=abc\n", "prod.env": "TOKEN=abc\n",
})) }), false)
// A colleague's clone: .env is there already, prod.env isn't. // A colleague's clone: .env is there already, prod.env isn't.
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"}) theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
@@ -104,7 +121,7 @@ func TestShareLinkElsewhereAsksWhere(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil { if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git") t.Skip("no git")
} }
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"})) link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true)
dir, _ := filepath.EvalSymlinks(t.TempDir()) dir, _ := filepath.EvalSymlinks(t.TempDir())
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0)) d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
d.wait = scryptWait d.wait = scryptWait