@@ -2,10 +2,16 @@ package parser
22
33import (
44 "bytes"
5+ "context"
6+ "crypto/sha256"
57 "errors"
68 "fmt"
9+ "io"
10+ "net/http"
711 "os"
12+ "path/filepath"
813 "strings"
14+ "time"
915
1016 "github.com/lets-cli/lets/config/config"
1117 "github.com/lets-cli/lets/config/path"
@@ -193,15 +199,113 @@ func isIgnoredMixin(filename string) bool {
193199 return strings .HasPrefix (filename , "-" )
194200}
195201
202+ type RemoteMixin struct {
203+ URL string
204+ Version string
205+
206+ mixinsDir string
207+ }
208+
209+ // Filename is name of mixin file (hash from url).
210+ func (rm * RemoteMixin ) Filename () string {
211+ hasher := sha256 .New ()
212+ hasher .Write ([]byte (rm .URL ))
213+
214+ if rm .Version != "" {
215+ hasher .Write ([]byte (rm .Version ))
216+ }
217+
218+ return fmt .Sprintf ("%x" , hasher .Sum (nil ))
219+ }
220+
221+ // Path is abs path to mixin file (.lets/mixins/<filename>).
222+ func (rm * RemoteMixin ) Path () string {
223+ return filepath .Join (rm .mixinsDir , rm .Filename ())
224+ }
225+
226+ func (rm * RemoteMixin ) persist (data []byte ) error {
227+ f , err := os .OpenFile (rm .Path (), os .O_CREATE | os .O_WRONLY , 0o755 )
228+ if err != nil {
229+ return fmt .Errorf ("can not open file %s to persist mixin: %w" , rm .Path (), err )
230+ }
231+
232+ _ , err = f .Write (data )
233+ if err != nil {
234+ return fmt .Errorf ("can not write mixin to file %s: %w" , rm .Path (), err )
235+ }
236+
237+ return nil
238+ }
239+
240+ func (rm * RemoteMixin ) exists () bool {
241+ return util .FileExists (rm .Path ())
242+ }
243+
244+ func (rm * RemoteMixin ) tryRead () ([]byte , error ) {
245+ if ! rm .exists () {
246+ return nil , nil
247+ }
248+ data , err := os .ReadFile (rm .Path ())
249+ if err != nil {
250+ return nil , fmt .Errorf ("can not read mixin config file at %s: %w" , rm .Path (), err )
251+ }
252+
253+ return data , nil
254+ }
255+
256+ func (rm * RemoteMixin ) download () ([]byte , error ) {
257+ // TODO: maybe create a client for this?
258+ ctx , cancel := context .WithTimeout (context .Background (), 60 * 5 * time .Second )
259+ defer cancel ()
260+
261+ req , err := http .NewRequestWithContext (
262+ ctx ,
263+ "GET" ,
264+ rm .URL ,
265+ nil ,
266+ )
267+ if err != nil {
268+ return nil , err
269+ }
270+
271+ client := & http.Client {
272+ Timeout : 15 * 60 * time .Second , // TODO: move to client struct
273+ }
274+
275+ resp , err := client .Do (req )
276+ if err != nil {
277+ return nil , fmt .Errorf ("failed to make request: %w" , err )
278+ }
279+
280+ defer resp .Body .Close ()
281+
282+ if resp .StatusCode == http .StatusNotFound {
283+ return nil , fmt .Errorf ("no such file at: %s" , rm .URL )
284+ } else if resp .StatusCode < 200 || resp .StatusCode > 299 {
285+ return nil , fmt .Errorf ("network error: %s" , resp .Status )
286+ }
287+
288+ data , err := io .ReadAll (resp .Body )
289+ if err != nil {
290+ return nil , fmt .Errorf ("failed to read response: %w" , err )
291+ }
292+
293+ return data , nil
294+ }
295+
196296func readAndValidateMixins (mixins []interface {}, cfg * config.Config ) error {
197- for _ , filename := range mixins {
198- if filename , ok := filename .(string ); ok { //nolint:nestif
297+ if err := cfg .CreateMixinsDir (); err != nil {
298+ return err
299+ }
300+
301+ for _ , mixin := range mixins {
302+ if filename , ok := mixin .(string ); ok { //nolint:nestif
199303 configAbsPath , err := path .GetFullConfigPath (normalizeMixinFilename (filename ), cfg .WorkDir )
200304 if err != nil {
201305 if isIgnoredMixin (filename ) && errors .Is (err , path .ErrFileNotExists ) {
202306 continue
203307 } else {
204- // complain non-existed mixin only if its filename does not starts with dash `-`
308+ // complain non-existed mixin only if its filename does not start with dash `-`
205309 return fmt .Errorf ("failed to read mixin config: %w" , err )
206310 }
207311 }
@@ -210,14 +314,52 @@ func readAndValidateMixins(mixins []interface{}, cfg *config.Config) error {
210314 return fmt .Errorf ("can not read mixin config file: %w" , err )
211315 }
212316
213- mixinCfg := config .NewMixinConfig (cfg . WorkDir , filename , cfg . DotLetsDir )
317+ mixinCfg := config .NewMixinConfig (cfg , filename )
214318 if err := parseMixinConfig (fileData , mixinCfg ); err != nil {
215319 return fmt .Errorf ("failed to load mixin config '%s': %w" , filename , err )
216320 }
217321
218322 if err := mergeConfigs (cfg , mixinCfg ); err != nil {
219323 return fmt .Errorf ("failed to merge mixin config %s with main config: %w" , filename , err )
220324 }
325+ } else if mixinMapping , ok := mixin .(map [string ]interface {}); ok {
326+ rm := & RemoteMixin {mixinsDir : cfg .MixinsDir }
327+ if url , ok := mixinMapping ["url" ]; ok {
328+ // TODO check if url is valid
329+ rm .URL , _ = url .(string )
330+ }
331+
332+ if version , ok := mixinMapping ["version" ]; ok {
333+ rm .Version , _ = version .(string )
334+ }
335+
336+ data , err := rm .tryRead ()
337+ if err != nil {
338+ return err
339+ }
340+
341+ if data == nil {
342+ data , err = rm .download ()
343+ if err != nil {
344+ return err
345+ }
346+ }
347+
348+ // TODO: what if multiple mixins have same commands
349+ // 1 option - fail and suggest use to namespace all commands in remote mixin
350+ // 2 option - namespace it (this may require specifying namespace in mixin config or in main config mixin section)
351+ mixinCfg := config .NewMixinConfig (cfg , rm .Filename ())
352+ if err := parseMixinConfig (data , mixinCfg ); err != nil {
353+ return fmt .Errorf ("failed to load remote mixin config '%s': %w" , rm .URL , err )
354+ }
355+
356+ if err := mergeConfigs (cfg , mixinCfg ); err != nil {
357+ return fmt .Errorf ("failed to merge remote mixin config %s with main config: %w" , rm .URL , err )
358+ }
359+
360+ if err := rm .persist (data ); err != nil {
361+ return fmt .Errorf ("failed to persist remote mixin config %s: %w" , rm .URL , err )
362+ }
221363 } else {
222364 return newConfigParseError (
223365 "must be a string" ,
0 commit comments