From 60725d7354c78db1a89ceac60b067e89dbc0e217 Mon Sep 17 00:00:00 2001 From: Ivan Velichko Date: Thu, 21 Mar 2024 20:38:09 +0000 Subject: [PATCH] content authoring wip --- cmd/content/content.go | 46 +++--- cmd/content/create.go | 219 ++++++++++--------------- cmd/content/list.go | 26 ++- cmd/content/remove.go | 11 +- cmd/content/sync.go | 2 +- go.mod | 2 +- go.sum | 2 + internal/api/challenges.go | 16 ++ internal/api/content.go | 23 ++- internal/api/{course.go => courses.go} | 16 ++ internal/api/tutorials.go | 16 ++ internal/content/content.go | 40 +++++ internal/labcli/cliutil.go | 11 ++ 13 files changed, 254 insertions(+), 176 deletions(-) rename internal/api/{course.go => courses.go} (83%) create mode 100644 internal/content/content.go diff --git a/cmd/content/content.go b/cmd/content/content.go index 9b91111..b8ef5fa 100644 --- a/cmd/content/content.go +++ b/cmd/content/content.go @@ -2,13 +2,17 @@ package content import ( "fmt" + "path/filepath" "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/iximiuz/labctl/internal/content" "github.com/iximiuz/labctl/internal/labcli" ) +var _ pflag.Value = (*content.ContentKind)(nil) + func NewCommand(cli labcli.CLI) *cobra.Command { cmd := &cobra.Command{ Use: "content [flags]", @@ -26,35 +30,23 @@ func NewCommand(cli labcli.CLI) *cobra.Command { return cmd } -type ContentKind string - -var _ pflag.Value = (*ContentKind)(nil) - -const ( - KindChallenge ContentKind = "challenge" - KindTutorial ContentKind = "tutorial" - KindCourse ContentKind = "course" -) - -func (k *ContentKind) Set(v string) error { - switch string(v) { - case string(KindChallenge): - *k = KindChallenge - case string(KindTutorial): - *k = KindTutorial - case string(KindCourse): - *k = KindCourse - default: - return fmt.Errorf("unknown content kind: %s", v) - } - - return nil +type dirOptions struct { + dir string } -func (k *ContentKind) String() string { - return string(*k) +func (o *dirOptions) AddDirFlag(fs *pflag.FlagSet) { + fs.StringVarP(&o.dir, "dir", "d", "", "Local directory with content files (default: $CWD/)") } -func (k *ContentKind) Type() string { - return "content-kind" +func (o *dirOptions) ContentDir(c content.Content) (string, error) { + dir := o.dir + if dir == "" { + dir = c.GetName() + } + + if abs, err := filepath.Abs(dir); err != nil { + return "", fmt.Errorf("couldn't get the absolute path of %s: %w", dir, err) + } else { + return abs, nil + } } diff --git a/cmd/content/create.go b/cmd/content/create.go index 6eb4941..9cb1347 100644 --- a/cmd/content/create.go +++ b/cmd/content/create.go @@ -4,21 +4,22 @@ import ( "context" "fmt" "net/url" + "os" "strings" - "github.com/charmbracelet/huh" "github.com/skratchdot/open-golang/open" "github.com/spf13/cobra" "github.com/iximiuz/labctl/internal/api" + "github.com/iximiuz/labctl/internal/content" "github.com/iximiuz/labctl/internal/labcli" ) type createOptions struct { - kind ContentKind + kind content.ContentKind name string - dir string + dirOptions // noSample bool } @@ -48,12 +49,8 @@ func newCreateCommand(cli labcli.CLI) *cobra.Command { // false, // `Don't create a sample piece of content`, // ) - flags.StringVar( - &opts.dir, - "dir", - "", - `Local directory to create the content in (default: current working directory)`, - ) + + opts.AddDirFlag(flags) return cmd } @@ -74,124 +71,92 @@ func runCreateContent(ctx context.Context, cli labcli.CLI, opts *createOptions) cli.PrintAux("Creating a new %s...\n", opts.kind) - // if _, err := os.Stat(opts.dir); err == nil { - // return fmt.Errorf("directory %s already exists - aborting to avoid overwriting existing files", opts.dir) - // } - - // if cwd, err := os.Getwd(); err != nil { - // return labcli.WrapStatusError(fmt.Errorf("couldn't get the current working directory: %w", err)) - // } else { - // opts.dir = cwd - // } - // if opts.dir == "" { - // if cwd, err := os.Getwd(); err != nil { - // return labcli.WrapStatusError(fmt.Errorf("couldn't get the current working directory: %w", err)) - // } else { - // opts.dir = cwd - // } - // } - // if absDir, err := filepath.Abs(opts.dir); err != nil { - // return labcli.WrapStatusError(fmt.Errorf("couldn't get the absolute path of %s: %w", opts.dir, err)) - // } else { - // opts.dir = absDir - // } - - // if err := os.MkdirAll(opts.dir, 0755); err != nil { - // return fmt.Errorf("couldn't create directory %s: %w", opts.dir, err) - // } + var cont content.Content switch opts.kind { - case KindChallenge: - if err := createChallenge(ctx, cli, opts); err != nil { - return err - } + case content.KindChallenge: + cont, err = createChallenge(ctx, cli, opts) - case KindTutorial: - if err := createTutorial(ctx, cli, opts); err != nil { - return err - } + case content.KindTutorial: + cont, err = createTutorial(ctx, cli, opts) - case KindCourse: - if err := createCourse(ctx, cli, opts); err != nil { - return err - } + case content.KindCourse: + cont, err = createCourse(ctx, cli, opts) } - return nil -} - -func createChallenge(ctx context.Context, cli labcli.CLI, opts *createOptions) error { - ch, err := cli.Client().CreateChallenge(ctx, api.CreateChallengeRequest{ - Name: opts.name, - }) if err != nil { - return fmt.Errorf("couldn't create challenge: %w", err) + return err } - cli.PrintAux("Created a new challenge %s\n", ch.PageURL) - if err := open.Run(ch.PageURL); err != nil { + cli.PrintAux("Created a new %s %s\n", cont.GetKind(), cont.GetPageURL()) + if err := open.Run(cont.GetPageURL()); err != nil { cli.PrintAux("Couldn't open the browser. Copy the above URL into a browser manually.\n") } - if err := cli.Client().PutMarkdown(ctx, api.PutMarkdownRequest{ - Kind: "challenge", - Name: ch.Name, - Content: `--- -title: Sample Challenge 444 -description: | - This is a sample challenge. - -kind: challenge -playground: docker + dir, err := opts.ContentDir(cont) + if err != nil { + return err + } -createdAt: 2024-01-01 -updatedAt: 2024-02-09 + if _, err := os.Stat(dir); err == nil { + cli.PrintErr("WARNING: Directory %s already exists and not empty.\n", dir) + cli.PrintErr("Skipping pulling the sample content files to avoid\noverwriting existing local files\n.") + cli.PrintErr("Use `labctl pull %s %s --dir ` to\npull the sample content files manually.\n") + return nil + } -difficulty: medium + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("couldn't create directory %s: %w", dir, err) + } -categories: - - containers + files, err := cli.Client().ListContentFiles(ctx, cont.GetKind(), cont.GetName()) + if err != nil { + return fmt.Errorf("couldn't list content files: %w", err) + } -tagz: - - containerd - - ctr - - docker + for _, file := range files { + // if err := cli.Client().DownloadContentFile(ctx, file, dir); err != nil { + // return fmt.Errorf("couldn't download content file %s: %w", file, err) + // } + fmt.Printf("Downloading %s\n", file) + } -tasks: - init_run_container_labs_are_fun: - init: true - run: | - docker run -q -d --name labs-are-fun busybox sleep 999999 ---- -# Sample Challenge + cli.PrintAux("Happy authoring!\n") + return nil +} -This is a sample challenge. You can edit this file in ... .`, - }); err != nil { - return fmt.Errorf("couldn't create a sample markdown file: %w", err) +func createChallenge(ctx context.Context, cli labcli.CLI, opts *createOptions) (content.Content, error) { + ch, err := cli.Client().CreateChallenge(ctx, api.CreateChallengeRequest{ + Name: opts.name, + }) + if err != nil { + return nil, fmt.Errorf("couldn't create challenge: %w", err) } - return labcli.NewStatusError(0, "Happy authoring..") + return ch, nil } -func createTutorial(ctx context.Context, cli labcli.CLI, opts *createOptions) error { - return nil +func createTutorial(ctx context.Context, cli labcli.CLI, opts *createOptions) (content.Content, error) { + t, err := cli.Client().CreateTutorial(ctx, api.CreateTutorialRequest{ + Name: opts.name, + }) + if err != nil { + return nil, fmt.Errorf("couldn't create tutorial: %w", err) + } + + return t, nil } -func createCourse(ctx context.Context, cli labcli.CLI, opts *createOptions) error { - ch, err := cli.Client().CreateCourse(ctx, api.CreateCourseRequest{ +func createCourse(ctx context.Context, cli labcli.CLI, opts *createOptions) (content.Content, error) { + c, err := cli.Client().CreateCourse(ctx, api.CreateCourseRequest{ Name: opts.name, Variant: api.CourseVariantModular, }) if err != nil { - return fmt.Errorf("couldn't create course: %w", err) - } - - cli.PrintAux("Created a new course %s\n", ch.PageURL) - if err := open.Run(ch.PageURL); err != nil { - cli.PrintAux("Couldn't open the browser. Copy the above URL into a browser manually.\n") + return nil, fmt.Errorf("couldn't create course: %w", err) } - return labcli.NewStatusError(0, "Happy authoring..") + return c, nil } func hasAuthorProfile(ctx context.Context, cli labcli.CLI) (bool, error) { @@ -221,45 +186,35 @@ func maybeCreateAuthorProfile(ctx context.Context, cli labcli.CLI) error { cli.PrintAux("Creating an author profile...\n") displayName := "John Doe" - if err := huh.NewInput(). - Title("Please enter your full name:"). - Prompt("?"). - Validate(func(v string) error { - if v == "" { - return fmt.Errorf("display name cannot be empty") - } - if !strings.Contains(v, " ") { - return fmt.Errorf("display name must contain at least two words") - } - if len(v) < 5 { - return fmt.Errorf("display name is too short") - } - if len(v) > 24 { - return fmt.Errorf("display name is too long") - } - return nil - }). - Value(&displayName). - Run(); err != nil { + if err := cli.Input("Please enter your full name:", "?", &displayName, func(v string) error { + if v == "" { + return fmt.Errorf("display name cannot be empty") + } + if !strings.Contains(v, " ") { + return fmt.Errorf("display name must contain at least two words") + } + if len(v) < 5 { + return fmt.Errorf("display name is too short") + } + if len(v) > 24 { + return fmt.Errorf("display name is too long") + } + return nil + }); err != nil { return err } var profileURL string - if err := huh.NewInput(). - Title("Please enter your X, LinkedIn, or other public profile URL:"). - Prompt("?"). - Validate(func(v string) error { - parsed, err := url.Parse(v) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - if parsed.Host == "" { - return fmt.Errorf("invalid URL: hostname is required") - } - return nil - }). - Value(&profileURL). - Run(); err != nil { + if err := cli.Input("Please enter your X, LinkedIn, or other public profile URL:", "?", &profileURL, func(v string) error { + parsed, err := url.Parse(v) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if parsed.Host == "" { + return fmt.Errorf("invalid URL: hostname is required") + } + return nil + }); err != nil { return err } diff --git a/cmd/content/list.go b/cmd/content/list.go index b980c10..1607709 100644 --- a/cmd/content/list.go +++ b/cmd/content/list.go @@ -8,11 +8,12 @@ import ( "gopkg.in/yaml.v2" "github.com/iximiuz/labctl/internal/api" + "github.com/iximiuz/labctl/internal/content" "github.com/iximiuz/labctl/internal/labcli" ) type listOptions struct { - kind ContentKind + kind content.ContentKind } func newListCommand(cli labcli.CLI) *cobra.Command { @@ -42,31 +43,40 @@ func newListCommand(cli labcli.CLI) *cobra.Command { type AuthoredContent struct { Challenges []api.Challenge `json:"challenges" yaml:"challenges"` Tutorials []api.Tutorial `json:"tutorials" yaml:"tutorials"` - // Courses []api.Course `json:"courses" yaml:"courses"` + Courses []api.Course `json:"courses" yaml:"courses"` } func runListContent(ctx context.Context, cli labcli.CLI, opts *listOptions) error { - var content AuthoredContent + var authored AuthoredContent - if opts.kind == "" || opts.kind == KindChallenge { + if opts.kind == "" || opts.kind == content.KindChallenge { challenges, err := cli.Client().ListAuthoredChallenges(ctx) if err != nil { return fmt.Errorf("cannot list authored challenges: %w", err) } - content.Challenges = challenges + authored.Challenges = challenges } - if opts.kind == "" || opts.kind == KindTutorial { + if opts.kind == "" || opts.kind == content.KindTutorial { tutorials, err := cli.Client().ListAuthoredTutorials(ctx) if err != nil { return fmt.Errorf("cannot list authored tutorials: %w", err) } - content.Tutorials = tutorials + authored.Tutorials = tutorials } - if err := yaml.NewEncoder(cli.OutputStream()).Encode(content); err != nil { + if opts.kind == "" || opts.kind == content.KindCourse { + courses, err := cli.Client().ListAuthoredCourses(ctx) + if err != nil { + return fmt.Errorf("cannot list authored courses: %w", err) + } + + authored.Courses = courses + } + + if err := yaml.NewEncoder(cli.OutputStream()).Encode(authored); err != nil { return err } diff --git a/cmd/content/remove.go b/cmd/content/remove.go index a99b4b1..fbd1e70 100644 --- a/cmd/content/remove.go +++ b/cmd/content/remove.go @@ -6,11 +6,12 @@ import ( "github.com/spf13/cobra" + "github.com/iximiuz/labctl/internal/content" "github.com/iximiuz/labctl/internal/labcli" ) type removeOptions struct { - kind ContentKind + kind content.ContentKind name string force bool } @@ -59,14 +60,14 @@ func runRemoveContent(ctx context.Context, cli labcli.CLI, opts *removeOptions) } switch opts.kind { - case KindChallenge: + case content.KindChallenge: return cli.Client().DeleteChallenge(ctx, opts.name) - case KindTutorial: + case content.KindTutorial: return cli.Client().DeleteTutorial(ctx, opts.name) - case KindCourse: - return fmt.Errorf("removing courses is not supported yet") + case content.KindCourse: + return cli.Client().DeleteCourse(ctx, opts.name) default: return fmt.Errorf("unknown content kind %q", opts.kind) diff --git a/cmd/content/sync.go b/cmd/content/sync.go index 232f0cf..5c7860c 100644 --- a/cmd/content/sync.go +++ b/cmd/content/sync.go @@ -51,7 +51,7 @@ func runSyncContent(ctx context.Context, cli labcli.CLI, opts *syncOptions) erro return fmt.Errorf("couldn't read index.md: %w", err) } - if err := cli.Client().PutMarkdown(ctx, api.PutMarkdownRequest{ + if err := cli.Client().PutContentMarkdown(ctx, api.PutContentMarkdownRequest{ Kind: "challenge", Name: "foobar-qux", Content: string(data), diff --git a/go.mod b/go.mod index a2f685f..60a6641 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.22.0 require ( github.com/briandowns/spinner v1.23.0 github.com/charmbracelet/huh v0.3.0 - github.com/docker/cli v25.0.5+incompatible + github.com/docker/cli v26.0.0+incompatible github.com/dustin/go-humanize v1.0.1 github.com/iximiuz/wsmux v0.0.2 github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a diff --git a/go.sum b/go.sum index 76f456e..7685811 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/docker/cli v25.0.4+incompatible h1:DatRkJ+nrFoYL2HZUzjM5Z5sAmcA5XGp+A github.com/docker/cli v25.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v25.0.5+incompatible h1:3Llw3kcE1gOScEojA247iDD+p1l9hHeC7H3vf3Zd5fk= github.com/docker/cli v25.0.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I= +github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= diff --git a/internal/api/challenges.go b/internal/api/challenges.go index 5185dbb..d660fc1 100644 --- a/internal/api/challenges.go +++ b/internal/api/challenges.go @@ -2,6 +2,8 @@ package api import ( "context" + + "github.com/iximiuz/labctl/internal/content" ) type Challenge struct { @@ -16,6 +18,20 @@ type Challenge struct { CompletionCount int `json:"completionCount" yaml:"completionCount"` } +var _ content.Content = (*Challenge)(nil) + +func (ch *Challenge) GetKind() content.ContentKind { + return content.KindChallenge +} + +func (ch *Challenge) GetName() string { + return ch.Name +} + +func (ch *Challenge) GetPageURL() string { + return ch.PageURL +} + type CreateChallengeRequest struct { Name string `json:"name"` } diff --git a/internal/api/content.go b/internal/api/content.go index 6ff5733..8dba6c7 100644 --- a/internal/api/content.go +++ b/internal/api/content.go @@ -2,15 +2,18 @@ package api import ( "context" + "net/url" + + "github.com/iximiuz/labctl/internal/content" ) -type PutMarkdownRequest struct { +type PutContentMarkdownRequest struct { Kind string `json:"kind"` Name string `json:"name"` Content string `json:"content"` } -func (c *Client) PutMarkdown(ctx context.Context, req PutMarkdownRequest) error { +func (c *Client) PutContentMarkdown(ctx context.Context, req PutContentMarkdownRequest) error { body, err := toJSONBody(req) if err != nil { return err @@ -23,3 +26,19 @@ func (c *Client) PutMarkdown(ctx context.Context, req PutMarkdownRequest) error resp.Body.Close() return nil } + +func (c *Client) ListContentFiles( + ctx context.Context, + kind content.ContentKind, + name string, +) ([]string, error) { + var files []string + if err := c.GetInto(ctx, "/content/files", url.Values{ + "kind": []string{kind.String()}, + "name": []string{name}, + }, nil, &files); err != nil { + return nil, err + } + + return files, nil +} diff --git a/internal/api/course.go b/internal/api/courses.go similarity index 83% rename from internal/api/course.go rename to internal/api/courses.go index e291a1b..b4b619a 100644 --- a/internal/api/course.go +++ b/internal/api/courses.go @@ -2,6 +2,8 @@ package api import ( "context" + + "github.com/iximiuz/labctl/internal/content" ) type Course struct { @@ -13,6 +15,20 @@ type Course struct { PageURL string `json:"pageUrl" yaml:"pageUrl"` } +var _ content.Content = (*Course)(nil) + +func (c *Course) GetKind() content.ContentKind { + return content.KindCourse +} + +func (c *Course) GetName() string { + return c.Name +} + +func (c *Course) GetPageURL() string { + return c.PageURL +} + type CourseVariant string const ( diff --git a/internal/api/tutorials.go b/internal/api/tutorials.go index d35a422..d4d81b8 100644 --- a/internal/api/tutorials.go +++ b/internal/api/tutorials.go @@ -2,6 +2,8 @@ package api import ( "context" + + "github.com/iximiuz/labctl/internal/content" ) type Tutorial struct { @@ -13,6 +15,20 @@ type Tutorial struct { PageURL string `json:"pageUrl"` } +var _ content.Content = (*Tutorial)(nil) + +func (t *Tutorial) GetKind() content.ContentKind { + return content.KindTutorial +} + +func (t *Tutorial) GetName() string { + return t.Name +} + +func (t *Tutorial) GetPageURL() string { + return t.PageURL +} + type CreateTutorialRequest struct { Name string `json:"name"` } diff --git a/internal/content/content.go b/internal/content/content.go new file mode 100644 index 0000000..2934722 --- /dev/null +++ b/internal/content/content.go @@ -0,0 +1,40 @@ +package content + +import "fmt" + +type ContentKind string + +const ( + KindChallenge ContentKind = "challenge" + KindTutorial ContentKind = "tutorial" + KindCourse ContentKind = "course" +) + +func (k *ContentKind) Set(v string) error { + switch string(v) { + case string(KindChallenge): + *k = KindChallenge + case string(KindTutorial): + *k = KindTutorial + case string(KindCourse): + *k = KindCourse + default: + return fmt.Errorf("unknown content kind: %s", v) + } + + return nil +} + +func (k *ContentKind) String() string { + return string(*k) +} + +func (k *ContentKind) Type() string { + return "content-kind" +} + +type Content interface { + GetKind() ContentKind + GetName() string + GetPageURL() string +} diff --git a/internal/labcli/cliutil.go b/internal/labcli/cliutil.go index 223002d..9a778ba 100644 --- a/internal/labcli/cliutil.go +++ b/internal/labcli/cliutil.go @@ -43,6 +43,8 @@ type CLI interface { Confirm(title, affirmative, negative string) bool + Input(title, prompt string, value *string, validate func(string) error) error + Version() string } @@ -133,6 +135,15 @@ func (c *cli) Confirm(title, affirmative, negative string) bool { return confirm } +func (c *cli) Input( + title string, + prompt string, + value *string, + validate func(string) error, +) error { + return huh.NewInput().Title(title).Prompt(prompt).Validate(validate).Value(value).Run() +} + func (c *cli) Version() string { return c.version }