From 2c5f9494641bc3e9ef4c898950b25b6c6fb4802e Mon Sep 17 00:00:00 2001 From: Michael Sverdlov Date: Tue, 24 Sep 2024 15:37:39 +0300 Subject: [PATCH] Introducing JFrog CLI AI commands assistant (#2703) --- build/npm/v2-jf/package-lock.json | 2 +- build/npm/v2-jf/package.json | 2 +- build/npm/v2/package-lock.json | 2 +- build/npm/v2/package.json | 2 +- docs/general/ai/help.go | 2 +- general/ai/cli.go | 36 +++++++- go.mod | 12 +-- go.sum | 40 ++++----- main.go | 2 - npm_test.go | 1 + utils/cliutils/cli_consts.go | 2 +- utils/cliutils/persistence.go | 136 +++++++++++++++++++++++++++++ utils/cliutils/persistence_test.go | 65 ++++++++++++++ utils/cliutils/utils.go | 19 ++-- utils/cliutils/utils_test.go | 11 ++- 15 files changed, 284 insertions(+), 50 deletions(-) create mode 100644 utils/cliutils/persistence.go create mode 100644 utils/cliutils/persistence_test.go diff --git a/build/npm/v2-jf/package-lock.json b/build/npm/v2-jf/package-lock.json index 757511226..136dd4eee 100644 --- a/build/npm/v2-jf/package-lock.json +++ b/build/npm/v2-jf/package-lock.json @@ -1,5 +1,5 @@ { "name": "jfrog-cli-v2-jf", - "version": "2.68.0", + "version": "2.69.0", "lockfileVersion": 1 } diff --git a/build/npm/v2-jf/package.json b/build/npm/v2-jf/package.json index 1489097a4..65fbdf5ad 100644 --- a/build/npm/v2-jf/package.json +++ b/build/npm/v2-jf/package.json @@ -1,6 +1,6 @@ { "name": "jfrog-cli-v2-jf", - "version": "2.68.0", + "version": "2.69.0", "description": "🐸 Command-line interface for JFrog Artifactory, Xray, Distribution, Pipelines and Mission Control 🐸", "homepage": "https://github.com/jfrog/jfrog-cli", "preferGlobal": true, diff --git a/build/npm/v2/package-lock.json b/build/npm/v2/package-lock.json index b912b0afb..65ebbbd94 100644 --- a/build/npm/v2/package-lock.json +++ b/build/npm/v2/package-lock.json @@ -1,5 +1,5 @@ { "name": "jfrog-cli-v2", - "version": "2.68.0", + "version": "2.69.0", "lockfileVersion": 2 } diff --git a/build/npm/v2/package.json b/build/npm/v2/package.json index da60ed7d0..493c5da77 100644 --- a/build/npm/v2/package.json +++ b/build/npm/v2/package.json @@ -1,6 +1,6 @@ { "name": "jfrog-cli-v2", - "version": "2.68.0", + "version": "2.69.0", "description": "🐸 Command-line interface for JFrog Artifactory, Xray, Distribution, Pipelines and Mission Control 🐸", "homepage": "https://github.com/jfrog/jfrog-cli", "preferGlobal": true, diff --git a/docs/general/ai/help.go b/docs/general/ai/help.go index 9c4e35142..3ae224eb0 100644 --- a/docs/general/ai/help.go +++ b/docs/general/ai/help.go @@ -3,5 +3,5 @@ package ai var Usage = []string{"how"} func GetDescription() string { - return "This AI-based interface converts your natural language inputs into fully functional JFrog CLI commands. This is an interactive command that accepts no arguments." + return "An AI-powered interface that converts natural language inputs into AI-generated JFrog CLI commands." } diff --git a/general/ai/cli.go b/general/ai/cli.go index a7e1c5b27..6527c000b 100644 --- a/general/ai/cli.go +++ b/general/ai/cli.go @@ -23,8 +23,10 @@ import ( type ApiCommand string const ( - cliAiAppApiUrl = "https://cli-ai-app-stg.jfrog.info/api/" + cliAiAppApiUrl = "https://cli-ai-app.jfrog.io/api/" askRateLimitHeader = "X-JFrog-CLI-AI" + // The latest version of the terms and conditions for using the AI interface. (https://docs.jfrog-applications.jfrog.io/jfrog-applications/jfrog-cli/cli-ai/terms) + aiTermsRevision = 1 ) type ApiType string @@ -41,9 +43,16 @@ func HowCmd(c *cli.Context) error { if c.NArg() > 0 { return cliutils.WrongNumberOfArgumentsHandler(c) } - log.Output(coreutils.PrintLink("This AI-based interface converts your natural language inputs into fully functional JFrog CLI commands.\n" + + log.Output(coreutils.PrintLink("This AI-powered interface converts natural language inputs into AI-generated JFrog CLI commands.\n" + + "For more information about this interface, see https://docs.jfrog-applications.jfrog.io/jfrog-applications/jfrog-cli/cli-ai\n" + "NOTE: This is an experimental version and it supports mostly Artifactory and Xray commands.\n")) + // Ask the user to agree to the terms and conditions. If the user does not agree, the command will not proceed. + // Ask this only once per JFrog CLI installation, unless the terms are updated. + if agreed, err := handleAiTermsAgreement(); err != nil || !agreed { + return err + } + for { var question string scanner := bufio.NewScanner(os.Stdin) @@ -147,12 +156,12 @@ func sendRestAPI(apiType ApiType, content interface{}) (response string, err err if err = errorutils.CheckResponseStatus(resp, http.StatusOK); err != nil { switch resp.StatusCode { case http.StatusInternalServerError: - err = errorutils.CheckErrorf("CLI-AI model endpoint is not available. Please try again later.") + err = errorutils.CheckErrorf("JFrog CLI-AI model endpoint is not available. Please try again later.") case http.StatusNotAcceptable: err = errorutils.CheckErrorf("The system is currently handling multiple requests from other users\n" + "Please try submitting your question again in a few minutes. Thank you for your patience!") default: - err = errorutils.CheckErrorf("CLI-AI server is not available. Please check your network or try again later. Note that the this command is supported while inside JFrog's internal network only.\n" + err.Error()) + err = errorutils.CheckErrorf("JFrog CLI-AI server is not available. Please check your network or try again later:\n" + err.Error()) } return } @@ -176,3 +185,22 @@ func sendRestAPI(apiType ApiType, content interface{}) (response string, err err response = strings.TrimSpace(string(body)) return } + +func handleAiTermsAgreement() (bool, error) { + latestTermsVer, err := cliutils.GetLatestAiTermsRevision() + if err != nil { + return false, err + } + if latestTermsVer == nil || *latestTermsVer < aiTermsRevision { + if !coreutils.AskYesNo("By using this interface, you agree to the terms of JFrog's AI Addendum on behalf of your organization as an active JFrog customer.\n"+ + "Review these terms at "+coreutils.PrintLink("https://docs.jfrog-applications.jfrog.io/jfrog-applications/jfrog-cli/cli-ai/terms")+ + "\nDo you agree?", false) { + return false, nil + } + if err = cliutils.SetLatestAiTermsRevision(aiTermsRevision); err != nil { + return false, err + } + log.Output() + } + return true, nil +} diff --git a/go.mod b/go.mod index c5fbbaaab..cf54abc93 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ replace ( require ( github.com/agnivade/levenshtein v1.1.1 github.com/buger/jsonparser v1.1.1 - github.com/docker/docker v27.2.1+incompatible + github.com/docker/docker v27.3.1+incompatible github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 github.com/jfrog/archiver/v3 v3.6.1 github.com/jfrog/build-info-go v1.10.0 @@ -148,11 +148,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.29.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 // indirect - go.opentelemetry.io/otel/metric v1.29.0 // indirect - go.opentelemetry.io/otel/sdk v1.29.0 // indirect - go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.opentelemetry.io/otel v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect + go.opentelemetry.io/otel/metric v1.30.0 // indirect + go.opentelemetry.io/otel/sdk v1.30.0 // indirect + go.opentelemetry.io/otel/trace v1.30.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.27.0 // indirect golang.org/x/mod v0.21.0 // indirect diff --git a/go.sum b/go.sum index f04c83146..82b1fbc0f 100644 --- a/go.sum +++ b/go.sum @@ -707,8 +707,8 @@ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.2.1+incompatible h1:fQdiLfW7VLscyoeYEBz7/J8soYFDZV1u6VW6gJEjNMI= -github.com/docker/docker v27.2.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -1195,18 +1195,18 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= -go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 h1:JAv0Jwtl01UFiyWZEMiJZBiTlv5A50zNs8lsthXqIio= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0/go.mod h1:QNKLmUEAq2QUbPQUfvw4fmv0bgbK7UlOSFCnXyfvSNc= -go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= -go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= -go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo= -go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= -go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= -go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= +go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8= +go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w= +go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ= +go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE= +go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= +go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc= +go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= @@ -1836,15 +1836,15 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go. google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= -google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd h1:BBOTEWLuuEGQy9n1y9MhVJ9Qt0BDu21X8qZs71/uPZo= -google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:fO8wJzT2zbQbAjbIoos1285VfEIYKDDY+Dt+WpTkh6g= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1886,8 +1886,8 @@ google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= -google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= -google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM= +google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/main.go b/main.go index 82af701a2..dab9625c5 100644 --- a/main.go +++ b/main.go @@ -274,12 +274,10 @@ func getCommands() ([]cli.Command, error) { Action: login.LoginCmd, }, { - Hidden: true, Name: "how", Usage: aiDocs.GetDescription(), HelpName: corecommon.CreateUsage("how", aiDocs.GetDescription(), aiDocs.Usage), BashComplete: corecommon.CreateBashCompletionFunc(), - Category: otherCategory, Action: ai.HowCmd, }, { diff --git a/npm_test.go b/npm_test.go index 5f7d598bc..8698fb0a0 100644 --- a/npm_test.go +++ b/npm_test.go @@ -84,6 +84,7 @@ func testNpm(t *testing.T, isLegacy bool) { assert.NoError(t, err) return } + log.Info("npm version:", npmVersion.GetVersion()) isNpm7 := isNpm7(npmVersion) // Temporarily change the cache folder to a temporary folder - to make sure the cache is clean and dependencies will be downloaded from Artifactory diff --git a/utils/cliutils/cli_consts.go b/utils/cliutils/cli_consts.go index 80bb1f5ce..9067c04a9 100644 --- a/utils/cliutils/cli_consts.go +++ b/utils/cliutils/cli_consts.go @@ -4,7 +4,7 @@ import "time" const ( // General CLI constants - CliVersion = "2.68.0" + CliVersion = "2.69.0" ClientAgent = "jfrog-cli-go" // CLI base commands constants: diff --git a/utils/cliutils/persistence.go b/utils/cliutils/persistence.go new file mode 100644 index 000000000..ef7747c97 --- /dev/null +++ b/utils/cliutils/persistence.go @@ -0,0 +1,136 @@ +package cliutils + +import ( + "encoding/json" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "os" + "path/filepath" + gosync "sync" +) + +const persistenceFileName = "persistence.json" + +// PersistenceFile holds various indicators that need to be persisted between CLI runs +type PersistenceFile struct { + LatestCliVersionCheckTime *int64 `json:"latestCliVersionCheckTime,omitempty"` + LatestAiTermsRevision *int `json:"latestAiTermsRevision,omitempty"` +} + +var ( + persistenceFilePath string + fileLock gosync.Mutex +) + +// getPersistenceFilePath ensures that the persistence file path is initialized +func getPersistenceFilePath() error { + if persistenceFilePath == "" { + homeDir, err := coreutils.GetJfrogHomeDir() + if err != nil { + return errorutils.CheckErrorf("failed to get JFrog home directory: " + err.Error()) + } + persistenceFilePath = filepath.Join(homeDir, persistenceFileName) + } + return nil +} + +// setCliLatestVersionCheckTime updates the latest version check time in the persistence file +func setCliLatestVersionCheckTime(timestamp int64) error { + fileLock.Lock() + defer fileLock.Unlock() + + info, err := getPersistenceInfo() + if err != nil { + return err + } + + info.LatestCliVersionCheckTime = ×tamp + return setPersistenceInfo(info) +} + +// getLatestCliVersionCheckTime retrieves the latest version check time from the persistence file +func getLatestCliVersionCheckTime() (*int64, error) { + fileLock.Lock() + defer fileLock.Unlock() + + info, err := getPersistenceInfo() + if err != nil { + return nil, err + } + + return info.LatestCliVersionCheckTime, nil +} + +// SetLatestAiTermsRevision updates the AI terms version in the persistence file +func SetLatestAiTermsRevision(version int) error { + fileLock.Lock() + defer fileLock.Unlock() + + info, err := getPersistenceInfo() + if err != nil { + return err + } + + info.LatestAiTermsRevision = &version + return setPersistenceInfo(info) +} + +// GetLatestAiTermsRevision retrieves the AI terms version from the persistence file +func GetLatestAiTermsRevision() (*int, error) { + fileLock.Lock() + defer fileLock.Unlock() + + info, err := getPersistenceInfo() + if err != nil { + return nil, err + } + + return info.LatestAiTermsRevision, nil +} + +// getPersistenceInfo reads the persistence file, creates it if it doesn't exist, and returns the persisted info +func getPersistenceInfo() (*PersistenceFile, error) { + if err := getPersistenceFilePath(); err != nil { + return nil, err + } + if exists, err := fileutils.IsFileExists(persistenceFilePath, false); err != nil || !exists { + if err != nil { + return nil, err + } + // Create an empty persistence file if it doesn't exist + pFile := &PersistenceFile{} + if err = setPersistenceInfo(pFile); err != nil { + return nil, errorutils.CheckErrorf("failed while attempting to initialize persistence file: " + err.Error()) + } + return pFile, nil + } + + data, err := os.ReadFile(persistenceFilePath) + if err != nil { + return nil, errorutils.CheckErrorf("failed while attempting to read persistence file: " + err.Error()) + } + + var info PersistenceFile + if err = json.Unmarshal(data, &info); err != nil { + return nil, errorutils.CheckErrorf("failed while attempting to parse persistence file: " + err.Error()) + } + + return &info, nil +} + +// setPersistenceInfo writes the given info to the persistence file +func setPersistenceInfo(info *PersistenceFile) error { + if err := getPersistenceFilePath(); err != nil { + return err + } + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return errorutils.CheckErrorf("failed while attempting to create persistence file: " + err.Error()) + } + + if err = os.WriteFile(persistenceFilePath, data, 0644); err != nil { + return errorutils.CheckErrorf("failed while attempting to write persistence file: " + err.Error()) + } + return nil +} diff --git a/utils/cliutils/persistence_test.go b/utils/cliutils/persistence_test.go new file mode 100644 index 000000000..cdf5826e7 --- /dev/null +++ b/utils/cliutils/persistence_test.go @@ -0,0 +1,65 @@ +package cliutils + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestSetAndGetLatestVersionCheckTime tests setting and getting the LatestCliVersionCheckTime +func TestSetAndGetLatestVersionCheckTime(t *testing.T) { + // Setup temporary directory + persistenceFilePath = filepath.Join(t.TempDir(), persistenceFileName) + + // Set the timestamp + timestamp := time.Now().UnixMilli() + err := setCliLatestVersionCheckTime(timestamp) + assert.NoError(t, err, "Failed to set LatestCliVersionCheckTime") + + // Get the timestamp + storedTimestamp, err := getLatestCliVersionCheckTime() + assert.NoError(t, err, "Failed to get LatestCliVersionCheckTime") + + // Assert equality + assert.Equal(t, timestamp, *storedTimestamp, "Stored timestamp does not match the set timestamp") +} + +// TestSetAndGetAiTermsVersion tests setting and getting the LatestAiTermsRevision +func TestSetAndGetAiTermsVersion(t *testing.T) { + // Setup temporary directory + persistenceFilePath = filepath.Join(t.TempDir(), persistenceFileName) + + // Set the AI terms version + version := 42 + err := SetLatestAiTermsRevision(version) + assert.NoError(t, err, "Failed to set LatestAiTermsRevision") + + // Get the AI terms version + storedVersion, err := GetLatestAiTermsRevision() + assert.NoError(t, err, "Failed to get LatestAiTermsRevision") + + // Assert equality + assert.Equal(t, version, *storedVersion, "Stored AI terms version does not match the set version") +} + +// TestPersistenceFileCreation tests if the persistence file is created when it doesn't exist +func TestPersistenceFileCreation(t *testing.T) { + // Setup temporary directory + persistenceFilePath = filepath.Join(t.TempDir(), persistenceFileName) + + // Ensure the persistence file doesn't exist + _, err := os.Stat(persistenceFilePath) + assert.ErrorIs(t, err, os.ErrNotExist, "Expected error to be os.ErrNotExist") + + // Trigger file creation by setting version check time + timestamp := time.Now().UnixMilli() + err = setCliLatestVersionCheckTime(timestamp) + assert.NoError(t, err, "Failed to set LatestCliVersionCheckTime") + + // Verify the persistence file was created + _, err = os.Stat(persistenceFilePath) + assert.False(t, os.IsNotExist(err), "Expected file to exist, but it does not") +} diff --git a/utils/cliutils/utils.go b/utils/cliutils/utils.go index f8bc4f592..26b2126f7 100644 --- a/utils/cliutils/utils.go +++ b/utils/cliutils/utils.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "os" - "path" "path/filepath" "strconv" "strings" @@ -695,25 +694,27 @@ func GetDetailedSummary(c *cli.Context) bool { return c.Bool("detailed-summary") || commandsummary.ShouldRecordSummary() } +// Check if the latest CLI version should be checked via the GitHub API to let the user know if a newer version is available. +// To avoid checking this for every run, we check only if the last check was more than 6 hours ago. +// Also, if the user set the JFROG_CLI_AVOID_NEW_VERSION_WARNING environment variable to true, we won't check. func shouldCheckLatestCliVersion() (shouldCheck bool, err error) { if strings.ToLower(os.Getenv(JfrogCliAvoidNewVersionWarning)) == "true" { return } - homeDir, err := coreutils.GetJfrogHomeDir() + latestVersionCheckTime, err := getLatestCliVersionCheckTime() if err != nil { return } - indicatorFile := path.Join(homeDir, "Latest_Cli_Version_Check_Indicator") - fileInfo, err := os.Stat(indicatorFile) - if err != nil && !os.IsNotExist(err) { - err = fmt.Errorf("couldn't get indicator file %s info: %s", indicatorFile, err.Error()) + timeNow := time.Now().UnixMilli() + if latestVersionCheckTime != nil && + (timeNow-*latestVersionCheckTime) < LatestCliVersionCheckInterval.Milliseconds() { + // Timestamp file exists and updated less than 6 hours ago, therefor no need to check version again return } - if err == nil && (time.Now().UnixMilli()-fileInfo.ModTime().UnixMilli()) < LatestCliVersionCheckInterval.Milliseconds() { - // Timestamp file exists and updated less than 6 hours ago, therefor no need to check version again + if err = setCliLatestVersionCheckTime(timeNow); err != nil { return } - return true, os.WriteFile(indicatorFile, []byte{}, 0666) + return true, nil } func getLatestCliVersionFromGithubAPI() (githubVersionInfo githubResponse, err error) { diff --git a/utils/cliutils/utils_test.go b/utils/cliutils/utils_test.go index 0d257bb5f..f9bf32b2e 100644 --- a/utils/cliutils/utils_test.go +++ b/utils/cliutils/utils_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" coretests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-cli/utils/tests" @@ -114,9 +115,7 @@ func testCheckNewCliVersionAvailable(t *testing.T, version string, shouldWarn bo } func TestShouldCheckLatestCliVersion(t *testing.T) { - // Create temp JFROG_HOME - cleanUpTempEnv := configtests.CreateTempEnv(t, false) - defer cleanUpTempEnv() + persistenceFilePath = filepath.Join(t.TempDir(), persistenceFileName) // Validate that avoiding the version check using an environment variable is working setEnvCallback := clientTestUtils.SetEnvWithCallbackAndAssert(t, JfrogCliAvoidNewVersionWarning, "true") @@ -134,4 +133,10 @@ func TestShouldCheckLatestCliVersion(t *testing.T) { shouldCheck, err = shouldCheckLatestCliVersion() assert.NoError(t, err) assert.False(t, shouldCheck) + + assert.NoError(t, setCliLatestVersionCheckTime(time.Now().UnixMilli()-LatestCliVersionCheckInterval.Milliseconds())) + // Third run, more than 6 hours between runs, so should return true + shouldCheck, err = shouldCheckLatestCliVersion() + assert.NoError(t, err) + assert.True(t, shouldCheck) }