Skip to content

Commit 16b65aa

Browse files
authored
Persist offline terms agreement (#620)
* Persist offline terms agreement * address PR comments
1 parent 5e704c6 commit 16b65aa

File tree

4 files changed

+110
-27
lines changed

4 files changed

+110
-27
lines changed

src/cmd/cli/command/commands.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ var RootCmd = &cobra.Command{
357357
term.Debug("Server error:", err)
358358
term.Warn("Please log in to continue.")
359359

360-
defer trackCmd(nil, "Login", P{"reason", err})
360+
defer func() { trackCmd(nil, "Login", P{"reason", err}) }()
361361
if err = cli.InteractiveLogin(cmd.Context(), client, gitHubClientId, cluster); err != nil {
362362
return err
363363
}
@@ -373,9 +373,9 @@ var RootCmd = &cobra.Command{
373373
if connect.CodeOf(err) == connect.CodeFailedPrecondition {
374374
term.Warn(prettyError(err))
375375

376-
defer trackCmd(nil, "Terms", P{"reason", err})
376+
defer func() { trackCmd(nil, "Terms", P{"reason", err}) }()
377377
if err = cli.InteractiveAgreeToS(cmd.Context(), client); err != nil {
378-
return err
378+
return err // fatal
379379
}
380380
}
381381
}
@@ -548,11 +548,10 @@ var generateCmd = &cobra.Command{
548548
if client.CheckLoginAndToS(cmd.Context()) != nil {
549549
// The user is either not logged in or has not agreed to the terms of service; ask for agreement to the terms now
550550
if err := cli.InteractiveAgreeToS(cmd.Context(), client); err != nil {
551-
// This might fail because the user did not log in. This is fine: we won't persist the terms agreement, but can proceed with the generation
551+
// This might fail because the user did not log in. This is fine: server won't save the terms agreement, but can proceed with the generation
552552
if connect.CodeOf(err) != connect.CodeUnauthenticated {
553553
return err
554554
}
555-
// TODO: persist the terms agreement in the state file
556555
}
557556
}
558557

src/pkg/cli/agree_tos.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@ import (
1212

1313
var ErrTermsNotAgreed = errors.New("You must agree to the Defang terms of service to use this tool")
1414

15-
func InteractiveAgreeToS(ctx context.Context, client client.Client) error {
15+
func InteractiveAgreeToS(ctx context.Context, c client.Client) error {
16+
if client.TermsAccepted() {
17+
// The user has already agreed to the terms of service recently
18+
if err := nonInteractiveAgreeToS(ctx, c); err != nil {
19+
term.Debug("unable to agree to terms:", err) // not fatal
20+
}
21+
return nil
22+
}
23+
1624
fmt.Println("Our latest terms of service can be found at https://defang.io/terms-service.html")
1725

1826
var agreeToS bool
@@ -28,15 +36,24 @@ func InteractiveAgreeToS(ctx context.Context, client client.Client) error {
2836
return ErrTermsNotAgreed
2937
}
3038

31-
return NonInteractiveAgreeToS(ctx, client)
39+
return NonInteractiveAgreeToS(ctx, c)
3240
}
3341

34-
func NonInteractiveAgreeToS(ctx context.Context, client client.Client) error {
42+
func NonInteractiveAgreeToS(ctx context.Context, c client.Client) error {
3543
if DoDryRun {
3644
return ErrDryRun
3745
}
3846

39-
if err := client.AgreeToS(ctx); err != nil {
47+
// Persist the terms agreement in the state file so that we don't ask again
48+
if err := client.AcceptTerms(); err != nil {
49+
term.Debug("unable to persist terms agreement:", err) // not fatal
50+
}
51+
52+
return nonInteractiveAgreeToS(ctx, c)
53+
}
54+
55+
func nonInteractiveAgreeToS(ctx context.Context, c client.Client) error {
56+
if err := c.AgreeToS(ctx); err != nil {
4057
return err
4158
}
4259
term.Info("You have agreed to the Defang terms of service")

src/pkg/cli/client/state.go

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"path/filepath"
77
"runtime"
8+
"time"
89

910
"github.com/DefangLabs/defang/src/pkg"
1011
"github.com/google/uuid"
@@ -22,25 +23,54 @@ func userStateDir() (string, error) {
2223
var (
2324
stateDir, _ = userStateDir()
2425
// StateDir is the directory where the state file is stored
25-
StateDir = filepath.Join(stateDir, "defang")
26-
27-
GetAnonID = func() string {
28-
state := State{AnonID: uuid.NewString()}
29-
30-
// Restore anonID from config file
31-
statePath := filepath.Join(StateDir, "state.json")
32-
if bytes, err := os.ReadFile(statePath); err == nil {
33-
json.Unmarshal(bytes, &state)
34-
} else { // could be not found or path error
35-
if bytes, err := json.MarshalIndent(state, "", " "); err == nil {
36-
os.MkdirAll(StateDir, 0700)
37-
os.WriteFile(statePath, bytes, 0644)
38-
}
39-
}
40-
return state.AnonID
41-
}
26+
StateDir = filepath.Join(stateDir, "defang")
27+
statePath = filepath.Join(StateDir, "state.json")
28+
state State
4229
)
4330

4431
type State struct {
45-
AnonID string
32+
AnonID string
33+
TermsAcceptedAt time.Time
34+
}
35+
36+
func initState(path string) State {
37+
state := State{AnonID: uuid.NewString()}
38+
if bytes, err := os.ReadFile(path); err == nil {
39+
json.Unmarshal(bytes, &state)
40+
} else { // could be not found or path error
41+
state.write(path)
42+
}
43+
return state
44+
}
45+
46+
func (state State) write(path string) error {
47+
if bytes, err := json.MarshalIndent(state, "", " "); err != nil {
48+
return err
49+
} else {
50+
os.MkdirAll(StateDir, 0700)
51+
return os.WriteFile(path, bytes, 0644)
52+
}
53+
}
54+
55+
func (state *State) acceptTerms() error {
56+
state.TermsAcceptedAt = time.Now()
57+
return state.write(statePath)
58+
}
59+
60+
func (state State) termsAccepted() bool {
61+
// Consider the terms accepted if the timestamp is within the last 24 hours
62+
return time.Since(state.TermsAcceptedAt) < 24*time.Hour
63+
}
64+
65+
func GetAnonID() string {
66+
state = initState(statePath)
67+
return state.AnonID
68+
}
69+
70+
func AcceptTerms() error {
71+
return state.acceptTerms()
72+
}
73+
74+
func TermsAccepted() bool {
75+
return state.termsAccepted()
4676
}

src/pkg/cli/client/state_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package client
22

33
import (
44
"os"
5+
"path/filepath"
56
"runtime"
67
"testing"
8+
"time"
79
)
810

911
func TestStateDir(t *testing.T) {
@@ -19,3 +21,38 @@ func TestStateDir(t *testing.T) {
1921
t.Errorf("userStateDir() returned unexpected directory: %v", stateDir)
2022
}
2123
}
24+
25+
func TestInitState(t *testing.T) {
26+
tmp := filepath.Join(t.TempDir(), "state.json")
27+
state := initState(tmp)
28+
if state.AnonID == "" {
29+
t.Errorf("initState() returned empty AnonID")
30+
}
31+
// 2nd call should read from same file
32+
state2 := initState(tmp)
33+
if state2.AnonID != state.AnonID {
34+
t.Errorf("initState() returned different AnonID on 2nd call")
35+
}
36+
}
37+
38+
func TestTerms(t *testing.T) {
39+
tmp := filepath.Join(t.TempDir(), "state.json")
40+
state := initState(tmp)
41+
if !state.TermsAcceptedAt.IsZero() {
42+
t.Errorf("initState() returned non-zero TermsAccepted")
43+
}
44+
if state.termsAccepted() {
45+
t.Errorf("TermsAccepted() returned true, expected false")
46+
}
47+
if err := state.acceptTerms(); err != nil {
48+
t.Errorf("AcceptTerms() returned error: %v", err)
49+
}
50+
if !state.termsAccepted() {
51+
t.Errorf("TermsAccepted() returned false, expected true")
52+
}
53+
// Old acceptance should not count
54+
state.TermsAcceptedAt = state.TermsAcceptedAt.Add(-25 * time.Hour)
55+
if state.termsAccepted() {
56+
t.Errorf("TermsAccepted() returned true, expected false")
57+
}
58+
}

0 commit comments

Comments
 (0)