Skip to content

Commit 39ae3cd

Browse files
authored
docs: Publish the command reference to GitHub Pages (#100)
1 parent 52d67c3 commit 39ae3cd

16 files changed

Lines changed: 509 additions & 8 deletions

File tree

‎.github/workflows/docs.yml‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Docs
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
# GitHub Pages rejects concurrent deploys.
12+
concurrency:
13+
group: pages
14+
cancel-in-progress: false
15+
16+
jobs:
17+
build:
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: read
21+
pages: read # read the site's base URL from the Pages API
22+
steps:
23+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24+
with:
25+
persist-credentials: false
26+
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
27+
with:
28+
go-version-file: go.mod
29+
# Hextra needs the extended build for its SCSS.
30+
- uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
31+
with:
32+
hugo-version: "0.165.0"
33+
extended: true
34+
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
35+
id: pages
36+
- name: Generate command reference
37+
run: go run ./cmd/docgen -out website/content
38+
- name: Build site
39+
working-directory: website
40+
run: hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
41+
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
42+
with:
43+
path: website/public
44+
# The pages the site was just built from, for the archive job to attach.
45+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
46+
with:
47+
name: reference
48+
path: website/content
49+
if-no-files-found: error
50+
retention-days: 1
51+
52+
deploy:
53+
needs: build
54+
runs-on: ubuntu-latest
55+
environment:
56+
name: github-pages
57+
url: ${{ steps.deployment.outputs.page_url }}
58+
permissions:
59+
pages: write # deploy to Pages
60+
id-token: write # verify the deployment originates here
61+
steps:
62+
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
63+
id: deployment
64+
65+
# The site only ever documents the newest release, so attach a copy to the
66+
# release itself for anyone staying on an older version.
67+
archive:
68+
needs: build
69+
if: github.ref_type == 'tag'
70+
runs-on: ubuntu-latest
71+
permissions:
72+
contents: write # upload a release asset
73+
steps:
74+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
75+
with:
76+
name: reference
77+
path: reference
78+
- run: tar -czf "flagsmith_${GITHUB_REF_NAME}_reference.tar.gz" reference
79+
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
80+
with:
81+
tag_name: ${{ github.ref_name }}
82+
files: flagsmith_*_reference.tar.gz
83+
fail_on_unmatched_files: true

‎.github/workflows/pull-request.yml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ jobs:
4747
with:
4848
extra-args: --all-files --hook-stage pre-push renovate-config-validator
4949

50+
# The site itself is only built on release tags, but generating the reference
51+
# is cheap and catches a broken generator here rather than mid-release.
52+
docs:
53+
name: docs generate
54+
runs-on: ubuntu-latest
55+
steps:
56+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
57+
with:
58+
persist-credentials: false
59+
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
60+
with:
61+
go-version-file: go.mod
62+
- run: go run ./cmd/docgen -out "$RUNNER_TEMP/reference"
63+
5064
install-ps1:
5165
name: install.ps1 lint
5266
runs-on: windows-latest

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
/flagsmith
22
/dist/
33
*.test
4+
5+
# Generated command reference and Hugo build output (see website/README.md).
6+
/website/content/
7+
/website/public/
8+
/website/resources/
9+
/website/.hugo_build.lock

‎README.md‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Flagsmith CLI
22

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

55
## Install
66

@@ -11,28 +11,38 @@ brew install Flagsmith/tap/flagsmith
1111
Or:
1212

1313
```sh
14-
curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh
14+
curl -fsSL https://get.flagsmith.com | sh
1515
```
1616

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

19+
<!-- x-release-please-start-version -->
20+
1921
```sh
20-
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
21-
curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh -s -- --help
22+
curl -fsSL https://get.flagsmith.com | sh -s -- --version v2.0.0-beta.3 --bin-dir /usr/local/bin --no-modify-path
23+
curl -fsSL https://get.flagsmith.com | sh -s -- --help
2224
```
2325

26+
<!-- x-release-please-end -->
27+
2428
`FLAGSMITH_CLI_VERSION`, `FLAGSMITH_INSTALL_DIR` and `FLAGSMITH_NO_MODIFY_PATH` do the same if exported first.
2529

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

28-
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 -->
29-
3032
On Windows:
3133

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

38+
Using npm:
39+
40+
```sh
41+
npm install -g @flagsmith/cli
42+
```
43+
44+
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 -->
45+
3646
## Build
3747

3848
```sh
@@ -56,7 +66,9 @@ flagsmith flag list # list the flags in the current environment
5666

5767
## Commands
5868

59-
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.
69+
Full reference for every command and flag: <https://flagsmith.github.io/flagsmith-cli/>.
70+
71+
Reading works against any Flagsmith instance. Changing flags — `flag update`, `flag enable`/`disable`, `flag reorder`, `flag delete` — needs Flagsmith 2.263.0 or newer.
6072

6173
- `flagsmith init` — bind the current directory to a project (writes `flagsmith.json`).
6274
- `flagsmith flag list` — list feature flags in the current environment.

‎cmd/docgen/main.go‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Command docgen renders the CLI's own help into the website's markdown.
2+
//
3+
// Usage:
4+
//
5+
// go run ./cmd/docgen -out website/content
6+
package main
7+
8+
import (
9+
"bytes"
10+
"flag"
11+
"fmt"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
16+
"github.com/spf13/cobra"
17+
"github.com/spf13/cobra/doc"
18+
19+
"github.com/Flagsmith/flagsmith-cli/v2/internal/cmd"
20+
)
21+
22+
func main() {
23+
if err := run(os.Args); err != nil {
24+
fmt.Fprintln(os.Stderr, err)
25+
os.Exit(1)
26+
}
27+
}
28+
29+
func run(args []string) error {
30+
flags := flag.NewFlagSet(filepath.Base(args[0]), flag.ContinueOnError)
31+
out := flags.String("out", "", "directory to write markdown pages to (required)")
32+
if err := flags.Parse(args[1:]); err != nil {
33+
return err
34+
}
35+
if *out == "" {
36+
flags.Usage()
37+
return fmt.Errorf("-out not set")
38+
}
39+
40+
// The directory is generated in full, so a deleted command leaves no page.
41+
if err := os.RemoveAll(*out); err != nil {
42+
return err
43+
}
44+
return write(cmd.Root(), *out)
45+
}
46+
47+
// write renders cmd and its subcommands beneath dir.
48+
func write(cmd *cobra.Command, dir string) error {
49+
// Otherwise the page ends with cobra's "Auto generated ... on <date>", which
50+
// makes the build non-reproducible.
51+
cmd.DisableAutoGenTag = true
52+
53+
var page bytes.Buffer
54+
// Hextra renders the front matter title as the page heading, and cobra opens
55+
// with one of its own, so drop cobra's to avoid printing it twice.
56+
fmt.Fprintf(&page, "---\ntitle: %q\nlinkTitle: %q\ndescription: %q\n",
57+
cmd.CommandPath(), cmd.Name(), cmd.Short)
58+
if !cmd.HasParent() {
59+
fmt.Fprint(&page, "sidebar:\n open: true\n")
60+
}
61+
fmt.Fprint(&page, "---\n\n")
62+
63+
var body bytes.Buffer
64+
if err := doc.GenMarkdownCustom(cmd, &body, linker(cmd)); err != nil {
65+
return err
66+
}
67+
page.Write(dropHeading(body.Bytes()))
68+
69+
// A command with subcommands owns a directory, so its page is that
70+
// directory's index; a leaf is a plain page beside its siblings.
71+
file := filepath.Join(dir, url(cmd.CommandPath())+".md")
72+
if cmd.HasAvailableSubCommands() {
73+
file = filepath.Join(dir, url(cmd.CommandPath()), "_index.md")
74+
}
75+
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
76+
return err
77+
}
78+
if err := os.WriteFile(file, page.Bytes(), 0o644); err != nil {
79+
return err
80+
}
81+
82+
for _, sub := range cmd.Commands() {
83+
if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() {
84+
continue
85+
}
86+
if err := write(sub, dir); err != nil {
87+
return err
88+
}
89+
}
90+
return nil
91+
}
92+
93+
// url is a command path's page relative to the site root, so
94+
// "flagsmith flag update" is "flag/update" and "flagsmith" is ".".
95+
func url(commandPath string) string {
96+
return filepath.Join(strings.Fields(commandPath)[1:]...)
97+
}
98+
99+
// linker turns the flat filenames cobra writes in its "SEE ALSO" links
100+
// (flagsmith_flag_update.md) into paths relative to the page being rendered, so
101+
// they hold up under the /flagsmith-cli/ prefix a project site is served under.
102+
// Command names contain no underscores, which TestCommandNamesAreURLSafe holds
103+
// true, so splitting on them recovers the command path.
104+
func linker(from *cobra.Command) func(string) string {
105+
return func(link string) string {
106+
to := strings.ReplaceAll(strings.TrimSuffix(link, ".md"), "_", " ")
107+
rel, err := filepath.Rel(url(from.CommandPath()), url(to))
108+
if err != nil {
109+
return link
110+
}
111+
return filepath.ToSlash(rel) + "/"
112+
}
113+
}
114+
115+
// dropHeading removes the "## <command path>" line cobra opens with, and the
116+
// blank line after it.
117+
func dropHeading(md []byte) []byte {
118+
if !bytes.HasPrefix(md, []byte("## ")) {
119+
return md
120+
}
121+
_, rest, found := bytes.Cut(md, []byte("\n"))
122+
if !found {
123+
return md
124+
}
125+
return bytes.TrimLeft(rest, "\n")
126+
}

‎cmd/docgen/main_test.go‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/Flagsmith/flagsmith-cli/v2/internal/cmd"
14+
)
15+
16+
func TestCommandNamesAreURLSafe(t *testing.T) {
17+
// Given
18+
root := cmd.Root()
19+
20+
// When / Then
21+
var visit func(*cobra.Command)
22+
visit = func(c *cobra.Command) {
23+
assert.NotContains(t, c.Name(), "_", "an underscore breaks the links between pages")
24+
assert.NotContains(t, c.Name(), " ", "a space breaks the page's path")
25+
assert.NotContains(t, c.Name(), "/", "a slash breaks the page's path")
26+
27+
for _, sub := range c.Commands() {
28+
visit(sub)
29+
}
30+
}
31+
visit(root)
32+
}
33+
34+
func TestWriteMirrorsCommandTree(t *testing.T) {
35+
// Given
36+
dir := t.TempDir()
37+
38+
// When
39+
require.NoError(t, write(cmd.Root(), dir))
40+
41+
// Then
42+
// the root command's own page is the site's home page
43+
assert.FileExists(t, filepath.Join(dir, "_index.md"))
44+
// a command with subcommands has a directory
45+
assert.FileExists(t, filepath.Join(dir, "flag/_index.md"))
46+
// a leaf command is a page inside its parent's directory
47+
assert.FileExists(t, filepath.Join(dir, "flag/update.md"))
48+
// a hidden command is not published
49+
assert.NoFileExists(t, filepath.Join(dir, "flag/create.md"))
50+
}
51+
52+
func TestWritePage(t *testing.T) {
53+
// Given
54+
dir := t.TempDir()
55+
require.NoError(t, write(cmd.Root(), dir))
56+
57+
// When
58+
page, err := os.ReadFile(filepath.Join(dir, "flag/update.md"))
59+
require.NoError(t, err)
60+
got := string(page)
61+
62+
// Then
63+
// the front matter titles the page with the full command path
64+
assert.Contains(t, got, `title: "flagsmith flag update"`)
65+
// the sidebar entry is the command's own name, unambiguous once nested.
66+
assert.Contains(t, got, `linkTitle: "update"`)
67+
// the description is the command's Short
68+
assert.Contains(t, got, `description: "Change a flag's state in the current environment"`)
69+
// the body is cobra's, taken from the command
70+
assert.Contains(t, got, "flagsmith flag update <feature> [flags]")
71+
// the link to the parent page is relative,
72+
assert.Contains(t, got, "](../)")
73+
// no link is left as cobra's flat filename
74+
assert.NotContains(t, got, "flagsmith_")
75+
// cobra's own heading is gone
76+
assert.NotContains(t, got, "## flagsmith flag update")
77+
// no generation date is stamped into the page
78+
assert.NotContains(t, got, "Auto generated")
79+
80+
// Guard against the assertions above passing on an empty file.
81+
require.NotEmpty(t, strings.TrimSpace(got))
82+
}

0 commit comments

Comments
 (0)