Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Docs

# The published reference must describe a version people can install, so it is
# built from release tags rather than from main. workflow_dispatch is here for
# fixing the site itself (theme, landing page) without cutting a release.
on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: read

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# One deployment at a time: GitHub Pages rejects concurrent deploys.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
# configure-pages reads the site's base URL from the Pages API. It never
# creates the site: enablement defaults to false, so Pages has to be set
# to build from Actions by hand, and read is all this job needs.
pages: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
# Hextra needs the extended build for its SCSS.
- uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
with:
hugo-version: "0.165.0"
extended: true
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
id: pages
- name: Generate command reference
run: go run ./cmd/docgen -out website/content
- name: Build site
working-directory: website
# configure-pages reports the correct base URL, which differs between a
# project site (/flagsmith-cli/) and a custom domain.
run: hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: website/public

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
permissions:
pages: write # deploy to Pages
id-token: write # verify the deployment originates here
steps:
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
id: deployment
14 changes: 14 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ jobs:
with:
extra-args: --all-files --hook-stage pre-push renovate-config-validator

# The site itself is only built on release tags, but generating the reference
# is cheap and catches a broken generator here rather than mid-release.
docs:
name: docs generate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- run: go run ./cmd/docgen -out "$RUNNER_TEMP/reference"

install-ps1:
name: install.ps1 lint
runs-on: windows-latest
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/flagsmith
/dist/
*.test

# Generated command reference and Hugo build output (see website/README.md).
/website/content/
/website/public/
/website/resources/
/website/.hugo_build.lock
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Flagsmith CLI

The next-generation Flagsmith command-line interface (work in progress).
The Flagsmith command-line interface helps you manage and roll out your feature flags without leaving your terminal.

## Install

Expand All @@ -11,28 +11,38 @@ brew install Flagsmith/tap/flagsmith
Or:

```sh
curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh
curl -fsSL https://get.flagsmith.com | sh
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

Installs to `$HOME/.local/bin` and adds it to your `PATH`. Options:

<!-- x-release-please-start-version -->

```sh
curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh -s -- --version v2.0.0 --bin-dir /usr/local/bin --no-modify-path
curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh -s -- --help
curl -fsSL https://get.flagsmith.com | sh -s -- --version v2.0.0-beta.3 --bin-dir /usr/local/bin --no-modify-path
curl -fsSL https://get.flagsmith.com | sh -s -- --help
```

<!-- x-release-please-end -->

`FLAGSMITH_CLI_VERSION`, `FLAGSMITH_INSTALL_DIR` and `FLAGSMITH_NO_MODIFY_PATH` do the same if exported first.

To pin the installer itself, fetch it at a commit you trust: `raw.githubusercontent.com/Flagsmith/flagsmith-cli/<sha>/install.sh`.

Alternatively, `go install github.com/Flagsmith/flagsmith-cli/v2@v2.0.0-beta.3` (installs as `flagsmith-cli`), or grab an archive from [Releases](https://github.com/Flagsmith/flagsmith-cli/releases). <!-- x-release-please-version -->

On Windows:

```powershell
irm https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.ps1 | iex
```

Using npm:

```sh
npm install -g @flagsmith/cli
```

Alternatively, `go install github.com/Flagsmith/flagsmith-cli/v2@v2.0.0-beta.3` (installs as `flagsmith-cli`), or grab an archive from [Releases](https://github.com/Flagsmith/flagsmith-cli/releases). <!-- x-release-please-version -->

## Build

```sh
Expand All @@ -56,6 +66,8 @@ flagsmith flag list # list the flags in the current environment

## Commands

Full reference for every command and flag: <https://flagsmith.github.io/flagsmith-cli/>.

Reading works against any Flagsmith instance. Changing flags — `flag update`, `flag enable`/`disable`, `flag reorder`, `flag delete` — needs Flagsmith 2.263.0 or newer, self-hosted or SaaS.

- `flagsmith init` — bind the current directory to a project (writes `flagsmith.json`).
Expand Down
198 changes: 198 additions & 0 deletions cmd/docgen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Command docgen renders the CLI's own help into markdown for the website.
//
// Pages are cobra's own markdown output, so the published reference cannot
// drift from the binary: every page is the command's Short, Long, Example and
// flags as the terminal would print them. Hidden commands and hidden flags are
// skipped.
//
// The command tree is mirrored as a directory tree — `flagsmith flag update`
// becomes flag/update.md, served at /flag/update/ — so the site navigation
// nests the way the CLI does. cobra's own tree walker is not used because it
// flattens everything into one directory, which leaves a sidebar of six
// commands all called "create".
//
// The output is the whole site content: the root command's page is the home
// page, so there is nowhere for a hand-written description of the CLI to drift
// out of step with the CLI.
//
// Usage:
//
// go run ./cmd/docgen -out website/content
package main

import (
"bytes"
"flag"
"fmt"
"os"
"path"
"path/filepath"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"

"github.com/Flagsmith/flagsmith-cli/v2/internal/cmd"
)

func main() {
if err := run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func run(args []string) error {
flags := flag.NewFlagSet(filepath.Base(args[0]), flag.ContinueOnError)
out := flags.String("out", "", "directory to write markdown pages to (required)")
if err := flags.Parse(args[1:]); err != nil {
return err
}
if *out == "" {
flags.Usage()
return fmt.Errorf("-out not set")
}

root := cmd.Root()
// Every page ends with cobra's "Auto generated ... on <date>" footer
// otherwise, which makes the build non-reproducible. cobra only inherits
// this from a parent while rendering its "SEE ALSO" block, so set it on
// each command rather than trusting it to propagate from the root.
forEach(root, func(c *cobra.Command) { c.DisableAutoGenTag = true })

// The whole directory is generated, so clear it: a renamed or deleted
// command must not leave an orphan page behind.
if err := os.RemoveAll(*out); err != nil {
return err
}
return write(root, newSite(root), *out)
}

// forEach applies fn to cmd and every command beneath it, skipping the ones
// cobra would not document.
func forEach(cmd *cobra.Command, fn func(*cobra.Command)) {
fn(cmd)
for _, sub := range cmd.Commands() {
if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() {
continue
}
forEach(sub, fn)
}
}

// site maps each documented command to where its page lives, and remembers the
// link names cobra will ask about while rendering cross-references.
type site struct {
// url is the served path of a command's page, e.g. "flag/update", relative
// to the site root.
url map[*cobra.Command]string
// byLink resolves the "flagsmith_flag_update.md" names cobra emits in its
// SEE ALSO block back to the command they point at.
byLink map[string]*cobra.Command
}

func newSite(root *cobra.Command) *site {
s := &site{url: map[*cobra.Command]string{}, byLink: map[string]*cobra.Command{}}
forEach(root, func(c *cobra.Command) {
// path.Join returns "" for the root command; "." keeps it a valid path
// for filepath.Rel when links are computed.
s.url[c] = path.Join(".", strings.Join(segments(c), "/"))
s.byLink[strings.ReplaceAll(c.CommandPath(), " ", "_")+".md"] = c
})
return s
}

// segments is a command's path below the root, so `flagsmith flag update`
// yields [flag update] and the root itself yields nothing.
func segments(cmd *cobra.Command) []string {
return strings.Fields(cmd.CommandPath())[1:]
}

// write renders cmd and its subcommands beneath dir.
func write(cmd *cobra.Command, s *site, dir string) error {
page, err := render(cmd, s)
if err != nil {
return err
}

// A command with subcommands owns a directory, so its own page has to be
// that directory's index; a leaf is a plain page beside its siblings.
file := filepath.Join(dir, filepath.Join(segments(cmd)...)+".md")
if cmd.HasAvailableSubCommands() {
file = filepath.Join(dir, filepath.Join(segments(cmd)...), "_index.md")
}
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
return err
}
if err := os.WriteFile(file, page, 0o644); err != nil {
return err
}

for _, sub := range cmd.Commands() {
if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() {
continue
}
if err := write(sub, s, dir); err != nil {
return err
}
}
return nil
}

// render produces one page: Hugo front matter, then cobra's markdown.
func render(cmd *cobra.Command, s *site) ([]byte, error) {
var body bytes.Buffer
if err := doc.GenMarkdownCustom(cmd, &body, s.linker(cmd)); err != nil {
return nil, err
}

var page bytes.Buffer
// Hextra renders the title as the page's heading, and cobra opens with the
// command path as a heading too. Keep the front matter one and drop
// cobra's, so the command name is not printed twice.
fmt.Fprintf(&page, "---\ntitle: %s\nlinkTitle: %s\ndescription: %s\n",
yamlString(cmd.CommandPath()), yamlString(cmd.Name()), yamlString(cmd.Short))
if !cmd.HasParent() {
// Expand the top of the sidebar on arrival.
fmt.Fprint(&page, "sidebar:\n open: true\n")
}
fmt.Fprint(&page, "---\n\n")
page.Write(dropHeading(body.Bytes()))
return page.Bytes(), nil
}

// linker resolves cobra's cross-reference filenames into links relative to the
// page being rendered, so they survive being served under a project-pages
// subpath such as /flagsmith-cli/.
func (s *site) linker(from *cobra.Command) func(string) string {
return func(link string) string {
to, ok := s.byLink[link]
if !ok {
return link
}
rel, err := filepath.Rel(s.url[from], s.url[to])
if err != nil {
return link
}
return filepath.ToSlash(rel) + "/"
}
}

// dropHeading removes the leading "## <command path>" line cobra writes, along
// with the blank line after it.
func dropHeading(md []byte) []byte {
if !bytes.HasPrefix(md, []byte("## ")) {
return md
}
_, rest, found := bytes.Cut(md, []byte("\n"))
if !found {
return md
}
return bytes.TrimLeft(rest, "\n")
}

// yamlString quotes a value for a front matter scalar, since a Short can
// contain a colon or a quote.
func yamlString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
Expand All @@ -49,7 +50,9 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/ohler55/ojg v1.28.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.39.0 // indirect
Expand Down
Loading