Skip to content

feat!: CLI v2 - #43

Draft
khvn26 wants to merge 143 commits into
mainfrom
feat/cli-v2
Draft

feat!: CLI v2#43
khvn26 wants to merge 143 commits into
mainfrom
feat/cli-v2

Conversation

@khvn26

@khvn26 khvn26 commented Jul 21, 2026

Copy link
Copy Markdown
Member

Contributes to #42.

To try it, make sure you've got Go 1.26+ installed and:

git clone -b feat/cli-v2 https://github.com/Flagsmith/flagsmith-cli.git
cd flagsmith-cli
go build -o flagsmith .

./flagsmith init
./flagsmith flag list

khvn26 added 30 commits July 16, 2026 15:56
Remove the TypeScript/oclif CLI entirely and start over in Go + cobra,
per the installation & auth design (docs/design/001-installation-and-auth.md).

The PoC implements interactive auth against the core OAuth 2.1 server
(Flagsmith/flagsmith#8029):

- `flagsmith login`: authorization-code + PKCE on a loopback listener
  (literal 127.0.0.1/[::1], RFC 8252), endpoints discovered via RFC 8414
  metadata, explicit `management-api` scope, `--no-browser` for headless
- credentials in the OS keychain, 0600-file fallback
- `flagsmith auth status` / `auth token`: transparent refresh-token
  rotation (server grace period 120s)
- `flagsmith logout`: server-side revocation + local removal

Verified end-to-end against ghcr.io/flagsmith/flagsmith:pr-8029.

beep boop
Tracks the scope rename in Flagsmith/flagsmith#8029. Verified e2e
against the rebuilt pr-8029 image (metadata now advertises admin-api).

beep boop
- oauth: full PKCE login against an in-process fake authorization server
  (asserts the exact authorization request and token-exchange contract,
  including verifier→challenge), state-mismatch rejection, access_denied,
  context cancellation, exchange failure, discovery errors, refresh
  rotation form, and revocation
- store: keychain round-trip (go-keyring mock), forced file fallback with
  0600 perms, corrupt-file and not-logged-in paths; make keychain removal
  in Delete best-effort so a broken keychain can't fail logout
- cmd: drives login --no-browser → auth status → auth token → logout
  end-to-end in-process, with the test playing the browser

beep boop
Implements the 02-authentication design (bar OIDC), red-green TDD:

- FLAGSMITH_API_KEY resolved ahead of stored logins, classified by shape:
  dotted -> Master API key (Api-Key header), dotless -> bearer token;
  ser.* keys and legacy 40-hex authtokens rejected with targeted errors
- flagsmith login --token / --token-stdin to store a Master API key
  (verified against /organisations/ before saving)
- credential store keyed per instance API URL (keychain account = URL,
  file fallback becomes a URL-keyed map); logout --api targets one
  instance and only revokes OAuth sessions
- auth status shows organisations and the active credential source;
  plaintext file fallback now warns visibly
- auth token prints whichever Admin API credential is active

Verified against staging (real browser PKCE login, refresh-token
rotation, per-instance isolation) and against a local pr-8029 stack
for the master-key paths with a real generated key.

beep boop
`flagsmith auth login` / `flagsmith auth logout` now work identically to
the top-level spellings (gh-style muscle memory). Commands are built by
constructors and registered under both parents; the flow tests are
parametrised over both shapes.

beep boop
…y doc

- `environment` now holds the client-side SDK key (env names have no
  server-side uniqueness constraint; keys are unique and public);
  `environmentKey` removed; schema anyOf(project, environment) so
  SDK-only configs are one field. Names come from a cosmetic name cache
  (os.UserCacheDir), never from the file.
- Sources collapse to flag/env/default; schema fields map 1:1 to flags
  and FLAGSMITH_* env vars; config --json works credless/offline.
- New 02-output-and-interactivity.md for cross-cutting conventions
  (TTY rules, --yes/--no-input/FLAGSMITH_NO_INPUT, --json, exit codes);
  auth and project-config docs renumbered to 03/04.

beep boop
The plaintext file store is no longer a silent fallback. When the OS
keychain is unavailable (headless Linux, containers, SSH), login fails
closed before starting any flow — browser logins probe storage first so
we never mint tokens we can't keep — and the error names both ways out:
FLAGSMITH_API_KEY, or --insecure-storage to opt into the 0600 plaintext
file. Refreshed sessions persist to whichever store they were loaded
from (never migrating between stores), and auth status labels the
opt-in store as "file (plaintext)".

beep boop
Matches the flagsmith.json field name (apiUrl) and the --config-path
convention from the project-config design. The alias is implemented via
cobra's global flag-name normalization, so it works in any position and
never appears in help. Also specs out-of-repo config discovery (cwd
only) and keys-only -e/FLAGSMITH_ENVIRONMENT in 04.

beep boop
…ONMENT (context)

FLAGSMITH_ENVIRONMENT_KEY is the SDK credential — takes precedence and
is the only home for server-side ser.* keys. FLAGSMITH_ENVIRONMENT is
the default-environment context (client-side key, mirroring
flagsmith.json). Also: 04 rewrite cleanups (env-key diff fix, typos,
cache path, out-of-repo discovery, keys-only -e), matrix rows updated
for the removed name-derivation path.

beep boop
Nearest-file discovery walking up to the git toplevel (cwd only outside
a repository), forward-compatible parsing (unknown fields warn, not
fail), and rejection of server-side keys in the environment field with
an error pointing at FLAGSMITH_ENVIRONMENT_KEY.

A schema-drift guard test cross-checks the parser's field list against
schema/flagsmith.json — and immediately caught $schema missing from the
schema's properties, which with additionalProperties:false would make
editors reject the $schema line that flagsmith init writes. Restored.

beep boop
- Global context flags (-p/--project, --organisation, -e/--environment,
  --sdk-api-url, -c/--config-path) with cli > env > config > default
  precedence per value; sdkApiUrl follows a non-default apiUrl, else
  Edge; ser.* keys rejected in context with a pointer at
  FLAGSMITH_ENVIRONMENT_KEY
- flagsmith config shows the resolved context with per-value sources;
  --json is the scripting interface; credless and offline, names are
  best-effort enrichment from the local cache
- name cache at os.UserCacheDir()/flagsmith/cache.json keyed by
  instance; auth status seeds organisation names opportunistically
- auth commands resolve their instance through the full chain, so a
  committed apiUrl finally works without --api-url

beep boop
Implements the rest of 04-project-config:

- flagsmith init: interactive flow (inline browser login when needed,
  org picker for multi-org users with the choice recorded, project
  picker with inline creation defaulting to the cwd name, environment
  picker writing the client-side key) and non-interactive flow
  (--project/--environment/--yes; missing input is a usage error).
  Values from an existing flagsmith.json act as prompt defaults, never
  as decisions; re-init shows a -/+ diff and confirms. The environments
  call doubles as the access check and seeds the name cache. $schema is
  pinned to the writing CLI's version tag.
- Admin API client: Projects, CreateProject, Environments, with
  paginated-or-bare-array list decoding.
- Exit codes per 02: usage errors (a prompt would have collected it)
  exit 2, everything else 1. --yes/--no-input aliases with
  FLAGSMITH_NO_INPUT; prompts require a real TTY.
- Bare `flagsmith` nudges towards init when there is neither project
  context nor credentials.

Verified live against staging (non-interactive init against a real
project). Also fixes a test-harness bug where cobra's sticky --help
local flag leaked across Execute calls.

beep boop
- --json is a global flag with FLAGSMITH_JSON_OUTPUT, now covering auth
  status (identity, organisations, source, expiry) and auth token
  alongside config
- browser rule enforced: without a TTY, login never opens a browser
  (implicit --no-browser); with --yes/--no-input it refuses outright,
  pointing at --token-stdin and FLAGSMITH_API_KEY instead of hanging CI
  for the login timeout
- logout's revoke warning moves to stderr; stream discipline pinned by
  tests with split stdout/stderr capture
- invalid promptable input (e.g. bad FLAGSMITH_PROJECT) is a usage
  error: exit 2, not 1
- fixes the FLASGMITH_JSON_OUTPUT typo in 02

beep boop
Replace the hand-rolled selector with huh: on a real terminal, pickers
are full-terminal selects with arrow keys, j/k, and type-to-/-filter for
long lists (a real win for the org picker); everywhere else huh's
accessible mode gives numbered, line-based, screen-reader-friendly
prompts that re-prompt on invalid input and terminate on EOF (never
hang). Colors come from termenv and respect NO_COLOR.

internal/prompt keeps its Select/Text/Confirm API and the
stdinIsTTY/rawTerminal cmd seam, so the accessible path is exactly what
the command tests drive. Accessible input is fed one byte at a time
because huh spins up a fresh scanner per prompt and a buffered source
would let one prompt swallow the next's input (regression-tested).

Verified live against staging in tmux: arrow-key org selection,
/-filtering the project list, and the ←/→ confirm on re-init. Docs
transcripts in 04 updated to the real huh rendering.

beep boop
Two bugs in how `flagsmith init` treated an existing file's organisation:

- It was dropped entirely: organisationID started at 0 and was only set
  by the multi-org picker, so any invocation that skipped the picker
  (single-org user, or explicit --project) rewrote the file without its
  organisation, showing `- "organisation": N` in the diff. Seed it from
  the resolved context.
- A config-file organisation suppressed the picker, so a multi-org user
  could never change it via re-init. Now only a flag/env organisation
  skips the picker (explicit decision); a config value is just the
  picker's pre-selected default, so re-init always re-offers the choice.

Verified live against staging in tmux: re-init re-offers the org picker
pre-selected to the current org.

beep boop
A project with no environments (typically one just created inline) had
nothing to pick from, so init wrote a config with no environment. Now,
interactively, it prompts to create one (default name Development),
creates it via the new api.CreateEnvironment, and records its key.
Non-interactively an empty project is written without an environment
rather than creating one silently.

The test fake now distinguishes an accessible-but-empty project (200,
empty list) from a no-access project (403), which the access check
relies on. Verified live against staging: a freshly created project
reports [] environments and init creates Development end-to-end.

beep boop
New internal/output package owns the JSON-vs-human decision once:
output.Render(w, data, opts, human) marshals the same data value that
feeds the human view — so the two can't drift — and Table/Detail/Success
give commands a consistent, NO_COLOR-aware, pipe-friendly vocabulary.

- --jq <expr> (global, implies --json) filters JSON output through a jq
  expression via itchyny/gojq: raw for string results, compact JSON
  otherwise. Works on every command that outputs data.
- Result model (02): stdout is the data result; ✓ confirmations,
  progress, warnings and prompts go to stderr. Migrated config, auth
  status (now a Detail view, single UsersMe call) and auth token onto
  Render; moved login/init/logout ✓ lines to stderr so mutations leave
  stdout empty (delete-style contract).
- JSON mirrors the resource shape (bare object/array); config keeps its
  bespoke keyed shape.

Verified live against staging: --jq filtering, migrated rendering, and
the stdout/stderr split.

beep boop
Answers the 02 gap where two init prompts (project name, environment
name) had no flag equivalent and the overwrite-confirm exited 1 instead
of 2.

- Prompt primitives now take the flag that supplies the same value and
  self-guard: called without a TTY they return a usage error (exit 2)
  naming that flag. A prompt can no longer be written without linking a
  flag, and can no longer hang non-interactively — the contract is
  structural, not per-call-site discipline. confirmOrYes centralises the
  yes/no case (--yes answers it; no TTY without it is exit 2 naming --yes).
- --create-project <name> / --create-environment <name> make creation
  flag-drivable, each mutually exclusive (exit 2) with its select
  counterpart; org resolution factored into resolveOrganisation so both
  the create and select paths share it.
- Re-init now carries a config-file environment forward non-interactively
  (it was silently dropped, the same class of bug as the earlier org drop).

Verified live against staging: non-interactive create of project +
environment, mutual-exclusion and overwrite-without-yes both exit 2.

beep boop
Move the result model out of 02 (it is CRUD-shaped, not general output)
into a new 05-crud.md defining the shared shape every resource command
follows: command layout, addressing, result model (human list shows a
count; JSON is a bare array), mutations, errors, and pagination (list
fetches all pages, --limit caps). Also fixes a typo in 02.

beep boop
Scope flags to feature states (the SDK view); project-level feature
definitions are a separate `features` resource, deferred. Resource
definition plus `flags list` as the minimum viable command: reads via
the SDK API with just an environment key (GET /api/v1/flags/), so it
works right after `init` and makes the init nudge real. get and the
enable/disable/set mutations (Admin API, v2-versioning branch) noted
as later work.

Also lands 05-crud.md (CRUD conventions) from the prior step.

beep boop
Implements the minimum from 06-flags.md, making the `flagsmith init`
next-step nudge real. `flags list` reads feature states from the SDK API
(GET /api/v1/flags/, X-Environment-Key) using the environment key from
context — FLAGSMITH_ENVIRONMENT_KEY (may be server-side) or the
client-side key in flagsmith.json — so it works with no Admin API
credentials at all.

Human output is a NAME/ENABLED/VALUE table with a count; --json/--jq
get the bare array as the API returns it (per 05's result model).
Verified live against staging with only a committed environment key.

beep boop
Colour the human tables after tabwriter has aligned them, not before:
Table bolds the header line and Detail colours the label column once the
plain text is laid out, so the ANSI bytes never count toward column
width. Restores the bold header (and cyan detail labels) while keeping
`flags list` and the detail views aligned; piped output stays plain.

beep boop
So a flagsmith.json written by a branch build resolves its schema (main
has no schema/flagsmith.json yet). Revert to main / a release tag before
merge.

beep boop
Point $schema at the feat/cli-v2 ref so it resolves like the files init
now writes. Revert to main / a release tag before merge.

beep boop
Adopt singular resource nouns for CRUD commands (gh/kubectl style):
`flagsmith flag list`, not `flags`. Renames the command and its cobra
identifiers, the init next-step nudge, tests, and the command examples
in 05-crud.md / 06-flags.md.

beep boop
Leftover from the rename to 04-project-config.md; accidentally re-added
in the previous commit by `git add -A`. 03 is authentication; the
project-config doc lives at 04.

beep boop
khvn26 added 24 commits July 27, 2026 16:08
The five flag → env → config → default switches in resolveContext were
copies differing only in names, transform, and fallback. contextValue
carries the precedence once; each value now declares its flag, env var,
transform (parseRef / verbatim / trailing-slash trim), config accessor,
and default. sdkApiUrl's follow-a-non-default-apiUrl arm becomes a
computed default rather than a fifth switch case.

beep boop
Almost every resource method was one of three copy-paste shapes — get
one, get list, send-and-decode — differing only in type and path. Three
package-level generic helpers (getOne, getMany, send; methods cannot
take type parameters) absorb the shapes, so each method body is now its
path plus one call. Methods with real extra logic (CreateProject's cap
probe, UpdateMVOption's serializer workaround, segment body stamping)
keep it, minus the boilerplate.

beep boop
UpdateFlag and DeleteSegmentOverride hand-rolled the same request —
marshal, POST, Content-Type, 403 → ErrWorkflowGated, accept 204/200 —
differing only in the path and DeleteSegmentOverride's 404 mapping.
postUpdateFlags carries the shared protocol once.

beep boop
The raw-passthrough type promised reuse that Go embedding can't deliver
— the marshal methods need the outer type — so Organisation, Project,
and Environment each hand-copied the alias dance. marshalOr and
unmarshalRaw hold the mechanics once; each embedder is now two
one-liners, and the next raw-mirroring type costs 2 lines, not 18.

beep boop
Five call sites repeated the same block: search-narrowed features
fetch, findFeatureByRef, the same not-found error and hint. Four
collapse onto requireFeature outright; flag list calls it only inside
its --feature branch, keeping the plain unfiltered fetch for the list
path. The project-scoped resolveFeatureID stays separate on purpose —
it returns an id, matches by name only, and carries its own error.

beep boop
Twelve commands repeated the same five lines around confirmOrYes: on
decline, print "Aborted; nothing <deleted/changed/written>." and stop.
confirmed resolves the confirmation and prints the abort line itself;
call sites shrink to a single guard.

beep boop
Eleven list commands repeated the same closure: empty message, rows,
output.Table, plural count footer. renderList carries the shape once —
each command now declares its empty text, headers, one row function,
and footer nouns (empty nouns skip the footer, as the variant and
server-key lists always did). The row function takes the index so the
segment table can join back to the raw rules for its condition count.

beep boop
The four entity delete commands fed label() the raw positional ref, so
deleting by id printed "44626 (44626)". label stays simple — the
id-or-name split belongs at the call sites: nameRef yields the ref's
name half (absorbing searchRef, whose one remaining caller wanted
exactly that), and the cached display name fills in where an entity has
one, so a name ref still labels with the canonical "name (id)" while an
id ref prints the bare id.

beep boop
Creating a segment or identity override with a value-only edit
inherited the environment default's value but hard-coded enabled=false
— so `flag update banner --value yo --segment beta` switched the flag
OFF for everyone in beta, the opposite of what a value edit implies.
New overrides now inherit the default's enabled state exactly like they
inherit its value; explicit --enable/--disable still win, and existing
overrides keep their current state.

Addresses Themis review on #43 (flag_update.go:150).

beep boop
parseRef turns an empty ref into nil while Changed stays true, so
explicitValue reported nil as an explicit decision and both call sites
blind-asserted it to int — `init --project ""` (an unset "$PROJECT_ID"
in CI) panicked with a stack trace. A nil value is absent input, not a
decision: explicitValue now says so, letting init fall through to its
usage error (exit 2) or the lone-organisation default, matching every
other resolver.

Addresses Themis review on #43 (init.go:52).

beep boop
getList accumulated pages into a nil slice, so an empty paginated
response marshalled to null, the caller's slice stayed nil, and
`organisation list --json` printed null — breaking the documented []
contract and making `--jq '.[]'` fail with "cannot iterate over: null".
Bare-array responses and the views built with make() already emitted
[], so sibling resources disagreed. The accumulator now starts empty
rather than nil.

Addresses Themis review on #43 (client.go:241).

beep boop
project get/update/delete resolved a name across every accessible
organisation, silently ignoring --organisation — a lone match in an
organisation the user excluded would resolve (and delete) there.
credentialContext now hands back the invocation context it already
resolved, and resolveProjectRefID scopes the listing to the
organisation context when one is set, keeping the all-organisations
behaviour when none is. The not-found error names the scope. This was
the only scope-ignoring resolver: every other entity's resolver takes
its parent id by construction, and organisations have no parent.

Addresses Themis review on #43 (project.go:31).

beep boop
jsonOutput() ignored --jq, and it doubles as control flow: environment
get skips the retrieve re-fetch for human output, so `environment get X
--jq '.field'` filtered the sparse list row and could return a silently
wrong answer for retrieve-only fields (and stderr chatter survived
under --jq alone). The flag's help text already promises "implies
--json"; the predicate now delivers it.

Addresses Themis review on #43 (root.go:81).

beep boop
`flagsmith api 'api/v1/projects/?organisation=3' -F page=2` appended a
second "?", fusing both parameters into one bogus organisation value —
page never reached the server. GET fields now join with "&" when the
path already carries a query string.

Addresses Themis review on #43 (api.go:74).

beep boop
resolveEnvironment's FLAGSMITH_ENVIRONMENT_KEY fallback skipped the
ser. guard the context layer applies to -e/FLAGSMITH_ENVIRONMENT/config
— and that variable is exactly where hintServerSideKey directs secrets.
A ser. value can never match the Admin environments list (client-side
keys only), so the not-found error echoed the secret into stderr and CI
logs. The fallback now refuses by naming the variable, never its value,
and resolveEnvironmentRef refuses ser. refs from any path (including a
typed positional) before fetching, with the recovery in the hint.

Addresses Themis review on #43 (resolve.go:85).

beep boop
The absolute path was the noisiest line in `flagsmith config` output —
/Users/<user>/... prefixes dwarf the value being shown. Paths under the
home directory now display with the conventional ~ prefix, for display
only; paths elsewhere (and a path-boundary near-miss like /Users/kimble
vs /Users/kim) stay verbatim.

beep boop
The 60s invocation deadline exists to catch stuck network calls, but it
kept ticking while a confirmation sat on screen — pause a minute over
"delete project X?", say yes, and the delete died with context deadline
exceeded. The prompt helpers (the choke point every interactive prompt
funnels through) now hand the elapsed time back by reinstalling a fresh
deadline when input returns.

Two adjacent latent problems fixed on the way: prompt stdin was only
initialised by init (every other command's interactive prompt worked
solely because the raw-TTY path reads the terminal directly), so
initPrompts moved to PersistentPreRunE; and the deadline now derives
from the root's context — cobra inherits the root context only while
the leaf's is nil, so deriving from the leaf chained onto a previous
in-process invocation's expired deadline (tests; production is
single-shot).

Addresses Themis review on #43 (root.go:26).

beep boop
Authorization-server metadata arrives over one unauthenticated GET, and
nothing verified it: a compromised /.well-known/* route (a mis-scoped
CDN or proxy rule) could point token_endpoint anywhere, and the next
quiet refresh would POST the refresh token — the long-lived credential —
to that host. Per RFC 8414 §3.3 the issuer must now equal the requested
API URL, and the endpoints that receive credentials (token, revocation)
must live on it over https. The authorization endpoint is browser-
visited and carries no CLI secret — Flagsmith serves it from the
dashboard host — so it may be cross-origin but must be https. Loopback
hosts are exempt from https so local instances keep working; verified
against production and staging metadata, both of which conform.

Known behaviour change: an instance whose configured API_URL differs
from the URL the user typed (aliases, port-forwards) is refused with an
error naming both URLs, and cleartext non-loopback instances lose
browser login (FLAGSMITH_API_KEY remains available there).

Addresses Themis review on #43 (oauth.go:39).

beep boop
Go's stdlib strips Authorization when a redirect leaves the original
host, but copies custom headers verbatim — and X-Environment-Key can
carry a server-side (ser.) secret via `flagsmith api --sdk`. A
CheckRedirect policy now drops it when the target host differs from the
original request's (same-host redirects keep it), and carries the
stdlib's ten-hop cap, which setting CheckRedirect otherwise replaces.

Addresses Themis review on #43 (httpx/client.go:56).

beep boop
colorDetailLine re-derived its columns by scanning the padded line for
two-space runs, justified by a false comment ("intra-cell spaces are
single" — values come from the API and may contain any spacing). The
scan happened to survive spacey values only because tabwriter pads
columns with three-plus spaces, and it painted a stray zero-width
escape sequence on rows without a source. The colourer now receives the
field it is painting: the line starts with the label and ends with the
source by construction, so the boundaries are exact, spacey values are
provably untouchable, and sourceless rows emit no escape bytes.

Addresses Themis review on #43 (output.go:181) — the reported
Acme-Inc misfire did not reproduce (padding makes the scan land in the
final column gap); the fix removes the fragile invariant and the stray
escapes the probe did find.

beep boop
The workflows still built the deleted Node CLI — npm install against no
package.json was the failing check on this PR, publish.yml would npm-
publish nothing on a tag, and release-please pointed at manifests this
branch removed. The PR workflow now builds and race-tests the Go module
on ubuntu, macos, and windows (one Go version from go.mod — apps aren't
libraries), lints via golangci-lint-action, and cross-compiles every
release target: the module graph has no cgo, so compilation is all the
architecture coverage that buys anything. Actions are pinned to commit
SHAs; tags are mutable.

golangci-lint (which a pre-existing nolint directive already
anticipated) runs the v2 defaults plus gofmt, with std-error-handling
exclusions and errcheck off in tests, where fakes discard errors by
design. Getting to zero findings surfaced real debris: the unused
apiClient wrapper (also flagged by the Themis review), an invalid-UTF-8
probe byte in a test, and two unmarked best-effort discards. Tidiness
and tests are not golangci-lint's job: go mod tidy -diff and go test
stay their own checks, locally and in Actions.

Hooks run via prek: hygiene from pre-commit-hooks (normalising three
docs and the schema once), golangci-lint's own hooks (-full so
whole-module analyses like unused actually work, -fmt, config-verify,
pinned to the action's version), tidy locally, tests on pre-push.
pre-commit.ci skips only what it provably cannot run — -full (module
graph), tidy and tests (system hooks) — and gets a chance at -fmt and
config-verify; the Actions lint job carries the full toolchain gate
either way. publish.yml and release-please.yml are deleted rather than
left loaded — the Go release pipeline is its own follow-up.

Addresses the CI half of Themis review on #43 (README.md:5) and its
dead-code finding D1 (client.go:43).

beep boop
The one failure the new Windows CI leg found: the test pinned HOME, but
os.UserHomeDir reads USERPROFILE there, and hardcoded forward-slash
paths never match a backslash home prefix. Paths now build with the
platform separator and both variables are pinned; abbreviateHome itself
was already portable.

beep boop
First pre-commit.ci run settled the experiment: golangci-lint-fmt
passes there (syntactic, no module graph), but config-verify fetches
its JSON schema from golangci-lint.run at run time, which pre-commit.ci
blocks. It stays in the local/Actions lane with the other toolchain
hooks.

beep boop
The version default was a branch name, baked into every init-written
config's $schema (a 404 once the branch is deleted) and every request's
User-Agent — and there was no --version at all. Version now defaults to
dev and resolves from the build info the toolchain embeds: release
builds stamp a tag via -ldflags, go install and modern source builds
carry their module (pseudo-)version, and a VCS-less build falls back to
dev — every binary identifies itself without pipeline discipline.
cobra's --version comes along via rootCmd.Version.

init pins $schema to the writing CLI's tag only for exact releases
(IsRelease); pre-releases, pseudo-versions, and dev builds reference
main, which always exists. The committed flagsmith.json and the
schema's $id move off the branch URL to main for the same reason, and
the design doc records the main fallback.

Addresses Themis review on #43 (version.go:12) and the $schema half of
its flagsmith.json finding.

beep boop
@Zaimwa9

Zaimwa9 commented Jul 27, 2026

Copy link
Copy Markdown

@themis-blindfold review this PR. Given the size of the work, please focus on the overall architecture (following CLIs best practices), the room for improvements and deduplication. Avoid focusing on per-line code optimization. Focus on the big picture. Make sure that it is robust security wise and no credentials have leaked

Comment thread internal/output/output.go
Comment on lines +160 to +161
for i, line := range splitLines(buf.String()) {
fmt.Fprintln(w, colorDetailLine(line, fields[i]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major · ⚡ Quick win

Detail zips tabwriter output lines against fields by index, so any field value containing a newline panics.

Observed: tabwriter treats a \n inside a cell as a line terminator, so a field whose Value spans two lines emits two output lines. splitLines then yields more lines than there are fields, and fields[i] runs off the end. The index is evaluated before color.NoColor is consulted inside colorDetailLine, so piping output does not avoid it.

Observed: values arrive unsanitised. Description is passed straight through at feature.go:146, flags.go:496, segment.go:435 and environment.go:66, and valueDisplay (flags.go:130-135) is a bare fmt.Sprint over the API's value. Table rows are safe only because truncateValue (flags.go:158) collapses whitespace first — detail views have no equivalent step.

Predicted: flagsmith feature get <feature> on a feature whose description was entered as more than one line, or flag get on a string flag holding a multi-line config blob, would panic with index out of range [N] with length N and a stack trace instead of rendering. TestDetailColoursSurviveSpaceyValues covers space runs but not newlines, which is why this survived 15b5954.

Fix direction: stop assuming a 1:1 line↔field mapping. Either collapse newlines in the value before it reaches Detail (the way truncateValue already does for tables), or have Detail render one field at a time so continuation lines are passed through uncoloured rather than consuming the next field's entry. A bare bounds guard is not enough — it would shift every subsequent field's colouring onto the wrong line.

Comment thread internal/cmd/feature.go
if err != nil {
return err
}
variants := toFeatureView(feat).Variants

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major · ⚡ Quick win

feature variant list renders null for a feature with no variants.

Observed: toFeatureView builds Variants with append (feature.go:71-74), so a standard (non-multivariate) feature yields a nil slice. renderList hands it to output.Render, which json.MarshalIndents nil to null. docs/design/05-crud.md:29-30 says a list is "a bare array … [] when empty". The getList normalisation added in f3fcc43 fixed the client layer; this slice is built in the command layer and never passes through it. Every other renderList call site uses make([]T, len(src)), so this is the one remaining site. TestFeatureVariant only lists a feature that has variants.

Predicted: flagsmith feature variant list banner --json --jq '.[]' on a standard feature would fail with a gojq "cannot iterate over: null" error rather than producing nothing.

Suggested change
variants := toFeatureView(feat).Variants
variants := toFeatureView(feat).Variants
if variants == nil {
variants = []variantView{}
}

Comment thread internal/config/config.go
)

// FileName is the project config file discovered in the working tree.
const FileName = "flagsmith.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major · 🏗️ Heavy lift

flagsmith.json is the exact filename the npm package this PR replaces writes flag state to, and nothing recognises or migrates it.

Observed: the published package is @flagsmith/cli (git show origin/main:package.json), whose only command is get, defaulting to --output ./flagsmith.json and writing flagsmith.getState() there (origin/main:src/commands/get/index.ts:27) so an app can do flagsmith.init({state}). Discover claims that filename by walking cwd→git toplevel, and Load emits one warning per unrecognised top-level key (config.go:241-249), printed to stderr on every command via context.go:188-190.

Observed: package.json, CHANGELOG.md and publish.yml are all deleted, so there is no npm deprecate step, no wrapper package and no README note; and the Go CLI registers no get (nor the designed evaluate), so v1's single use case has no successor.

Predicted: a user who installs v2 in a repo that still has a v1-generated flagsmith.json would see a Warning: unknown field "flags" in … line per state key prefixed to every command, with no project or environment resolved. flagsmith init there rewrites the file — Extra (config.go:38-77) preserves the state keys so the SDK bootstrap survives, but the file becomes half config, half state. Meanwhile nothing tells v1 users the package is frozen.

Fix direction: recognise a v1 state file (flags or environmentID at the top level) and fail with a migration hint instead of warning per key; and either keep publish.yml long enough to ship one deprecating @flagsmith/cli release, or state in the README that v1 is frozen and what replaces get.

Comment thread internal/cmd/root.go

// jsonOutput reports whether machine-readable output was requested.
func jsonOutput() bool {
return jsonFlag || jqFlag != "" || os.Getenv("FLAGSMITH_JSON_OUTPUT") != ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nit · ⚡ Quick win

FLAGSMITH_JSON_OUTPUT=0 turns JSON output on — the != "" test makes every value truthy, and FLAGSMITH_NO_INPUT (prompts.go:29) and FLAGSMITH_DEBUG (httpx/client.go:53) share it, so FLAGSMITH_NO_INPUT=false in CI silently disables prompting.

Comment thread internal/cache/cache.go
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nit · ⚡ Quick win

The name cache is written world-readable — organisation, project and segment names land at os.UserCacheDir()/flagsmith/cache.json with mode 0644 inside a 0755 directory (line 91), readable by every local user, and nothing else the CLI writes needs to be.

Suggested change
return os.WriteFile(path, b, 0o644)
return os.WriteFile(path, b, 0o600)

Comment thread internal/httpx/client.go
Comment on lines +58 to +62
// Go strips Authorization when a redirect crosses hosts, but copies
// custom headers verbatim — and X-Environment-Key can carry a
// server-side (ser.) secret. Give it the same treatment. Setting
// CheckRedirect replaces the default policy, so the ten-hop cap
// comes with it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nit · ⚡ Quick win

The comment overstates what Go does, and comparing Host alone misses a scheme downgrade — Go drops Authorization only for a target that is not a subdomain or exact match of the initial domain (so api.hostevil.api.host keeps it), and an https://api.host/xhttp://api.host/y redirect passes this check unchanged, re-sending both Authorization and X-Environment-Key in cleartext.

Comment thread internal/cmd/auth.go
Example: ` flagsmith auth token

# e.g. drive curl with it
curl -H "Authorization: Api-Key $(flagsmith auth token)" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nit · ⚡ Quick win

The command's own example puts the live credential in curl's argv, where any local user can read it via ps for the life of the request — flagsmith auth token | curl -H @- …, or pointing at flagsmith api, teaches the safe shape instead.

@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis judgement: 🔴 Hold the merge

A full TypeScript→Go rewrite: ~19.5k lines of new Go replacing a one-command npm package with a complete admin CLI. The architecture is genuinely good — one output.Render feeding both JSON and human views, one precedence chain for every context value, one hint table for every error sentinel (with a source-scanning meta-test that fails until a new sentinel gets a hint decision), keychain-only token storage, and PKCE-with-issuer-validation OAuth. Fourteen of the eighteen findings from the last pass are verified fixed in this checkout, each with a regression test, and CI is now real and green on Linux/macOS/Windows.

Three things hold it back. The credential-trust gap flagged last time is untouched: a flagsmith.json discovered anywhere between cwd and the git toplevel still sets the process-wide API URL, and FLAGSMITH_API_KEY is attached to whatever host that is with no check — the scheme isn't validated either, so it can be plain http://. Two new crash/output bugs are in the render layer. And the replaced npm package shares its filename with the new config file, with no migration path in either direction.

On the explicit brief: no credentials leaked. The committed root flagsmith.json holds a client-side environment key (public by design) plus a live project and organisation id; the ser. keys in the design docs are fabricated — they share shuffled character runs with each other (8kQqZ9pJmNb, K2mVsGdXhZ), so they are illustrative, not transcribed.

🎯 Correctness 3/5
🧪 Test coverage 4/5
📐 Code quality 4/5
🚀 Product impact 5/5

🔴 Blockers

  • An auto-discovered flagsmith.json still redirects env-var credentials to any host (existing thread on internal/cmd/context.go). Observed: no mitigation was added — config.Discover (config.go:180-203) applies the nearest file with no opt-in, its apiUrl becomes the process-wide apiURL (context.go:191), and loadCredential (auth.go:89-102) attaches FLAGSMITH_API_KEY/FLAGSMITH_ACCESS_TOKEN to it; sdkApiUrl does the same for FLAGSMITH_ENVIRONMENT_KEY (api.go:99-107). The only validation Load performs is the ser. prefix on environment; unknown fields warn, a changed host does not. Observed: additionally, nothing anywhere validates the URL's scheme — secureScheme/validCredentialEndpoint exist only in internal/auth/oauth.go, so apiUrl: "http://…" sends the Master key in cleartext while flagsmith login refuses the same downgrade. Predicted: a CI runner or shell with FLAGSMITH_API_KEY exported, run inside a checkout containing {"project":1,"apiUrl":"https://evil.example"}, would send Authorization: Api-Key <master key> there on the first request. The provenance is already tracked (resolved.Source), so gating an env-var credential on a sourceConfig host — or at minimum printing the resolved instance to stderr before the first authenticated call — is cheap.

🟠 Majors

  • internal/output/output.go — detail views panic on a field value containing a newline; see inline.
  • internal/cmd/feature.gofeature variant list --json renders null for a non-multivariate feature; see inline.
  • internal/config/config.go — the new config file collides with the replaced npm package's output file, with no migration path; see inline.
  • The edge identity-override list still does not follow pagination (existing thread on internal/api/client.go:786) — unaddressed (unverified). Observed: EdgeIdentityOverrides (client.go:776-795) still uses the single-request c.get and decodes only results, while its core sibling goes through getList; the comment asserting "no pagination" is a claim, not evidence, and client_test.go:704-721 exercises a next-link cursor on the neighbouring edge-identities endpoint. Predicted: on an edge project where a feature has more overrides than the server's page size, flagsmith flag list --feature X --identity would silently truncate and report the truncated count as the total. One curl of /api/v1/environments/<key>/edge-identity-overrides?feature=N against an over-page-size edge environment settles it; if a next link appears, route the call through getList.
  • A live project/organisation is still committed at the repo root and the .gitignore entry is still removed (existing thread on flagsmith.json) — partially addressed. Observed: the $schema half is fixed (it now pins to main, and version.IsRelease keeps a branch name out of generated files), but flagsmith.json remains committed with project: 44121 / organisation: 13, and git show origin/main:.gitignore shows the flagsmith.json line this PR drops. Observed: cmd_test.go has no TestMain and never sets FLAGSMITH_CONFIG_PATH, so tests run with cwd inside the repo and inherit this file through Discover. Predicted: any contributor running the binary anywhere in a checkout silently targets that project — including environment delete and flag update.
  • No release or distribution channel for the Go binary (existing thread on README.md) — the harmful half is fixed, the rest is not. Observed: the Node workflows are deleted rather than left loaded, so a tag no longer publishes nothing, and pull-request.yml now runs go build, go test -race, golangci-lint and a six-target cross-compile with SHA-pinned actions — all green on this head. Observed: there is still no goreleaser config, release workflow, install script or Docker image, while docs/design/01-installation.md:7-17 promises "single static binary via goreleaser, from GitHub Releases", curl -fsSL https://get.flagsmith.com | sh and Flagsmith/setup-cli@v1, and the README's entire install story is go build -o flagsmith . with no note that distribution is a follow-up.

🧹 Nits

  • internal/cmd/root.goFLAGSMITH_JSON_OUTPUT=0 enables JSON; see inline.
  • internal/cache/cache.go — the name cache is written world-readable; see inline.
  • internal/httpx/client.go — the redirect policy's comment overstates Go's behaviour and the check ignores a scheme downgrade; see inline.
  • internal/cmd/auth.goauth token's own example puts the credential in curl's argv; see inline.
  • Delete confirmations still echo the typed argument for features (existing thread on internal/cmd/organisation.go) — mostly fixed. Observed: organisation.go, project.go, segment.go and environment.go now print the canonical name, but feature.go:285 uses nameRef(args[0]) and findFeature matches case-insensitively (flags.go:377), so feature delete ONBOARDING --yes still confirms ONBOARDING (55); feature.go:455,535,541 (variant add/delete) print args[0] although feat.Name is in hand.
  • Design docs still specify surface that does not exist (existing thread on docs/design/12-evaluate.md) — unaddressed. Observed: evaluate/eval registers no command, flag update has no --weight (07-flags.md:241-319) and no --limit exists (05-crud.md:45); every page carries Status: draft/poc and 12 carries Status: draft, a distinction that does not tell a reader which pages describe shipped behaviour, and the README links all twelve undifferentiated.

⚖️ Acknowledged

Nothing acknowledged: no thread is resolved and no reply accepts a trade-off. The two design pushbacks from @khvn26 (keeping init's config diff, and init rather than link) are on @Zaimwa9's threads, not findings of mine, and both were referred to the RFC.

📝 Walkthrough
  • Rewrite — the oclif get command is gone; in its place a cobra CLI covering auth, organisation/project/environment/feature/segment/flag CRUD, flag reorder, and a raw api escape hatch.
  • Contextflagsmith.json discovered cwd→git toplevel, with flag → env → file → default precedence resolved per value and provenance kept for flagsmith config.
  • Auth — browser OAuth (PKCE S256, ephemeral loopback listener, crypto/rand state verified before anything else) with keychain-only storage and no plaintext fallback, plus FLAGSMITH_API_KEY/FLAGSMITH_ACCESS_TOKEN for CI.
  • Output — one data value feeds --json/--jq and the human table/detail views; colour is applied after tabwriter alignment so escapes never affect column widths.
  • CI — Node pipeline retired; Go build + -race tests on three OSes, golangci-lint, go mod tidy -diff, six-target cross-compile, actions pinned to commit SHAs. publish.yml and release-please.yml deleted with no Go successor.
  • Prior review — fourteen findings fixed across the fix:/refactor:/perf: commits, each with a regression test; four remain open.
🧪 How to verify
  1. flagsmith feature get <feature whose description has two lines> — expect a detail view, observe an index-out-of-range panic from output.Detail.
  2. flagsmith feature variant list <standard, non-multivariate feature> --json --jq '.[]' — expect no output, observe a gojq null-iteration error.
  3. In a scratch dir: printf '{"project":1,"apiUrl":"http://127.0.0.1:8081"}' > flagsmith.json, start nc -l 8081, then FLAGSMITH_API_KEY=<key> flagsmith flag list — check whether the key reaches that listener.
  4. Drop a v1-style state file ({"flags":{},"environmentID":"x"}) at a repo root, run any command, then flagsmith init — check the warning volume and what gets written back.
  5. curl /api/v1/environments/<key>/edge-identity-overrides?feature=N on an edge environment with more overrides than one page — look for a next link.

Automate: a Detail table test with \n in a value, and one renderList case per list command asserting [] rather than null when empty.

Product take: A major capability, not an increment — this turns a package that could dump flag state into a file into a CLI that can administer the whole tenancy chain, script against it with --jq, and authenticate a human or a CI job. The gaps are at the edges of the release, not the centre of the design: nothing ships the binary yet, and the users of the package being replaced are not told anything.

Big picture: internal/cmd is a single package owning command definitions, context precedence, credential resolution, ref resolution, rendering and prompting, coordinated through ~50 package-level flag variables plus mutable apiURL, cancelTimeout/timeoutParent, promptIn and credCache. The cost is already visible in this diff: resetFlags() walks every command's flag set and then hand-patches four variables that "do not reset cleanly", and refreshCommandTimeout cancels a context that init.go:119 captured earlier — safe today only because init opts out of the deadline entirely. Each new command adds globals the test reset must know about, and a missed one leaks between tests silently. The smallest useful move is to let each command group hold its own flags in a struct and pass the resolved context and credential as values rather than through package state.

🧭 Assumptions & unverified claims
  • Whether /api/v1/environments/{key}/edge-identity-overrides paginates is unverified — no instance was reachable to check for a next link.
  • Feature descriptions and string flag values are assumed to permit newlines (a dashboard textarea over a Django text field); this was not confirmed against a live instance, though nothing in the CLI sanitises them either way.
  • Whether gojq's default compiler exposes env to a --jq expression was not confirmed from its source; a test asserting --jq 'env' is empty would close it cheaply.
  • The @flagsmith/cli v1 behaviour is read from origin/main (src/commands/get/index.ts, package.json), not from the published registry entry.
  • No Go toolchain was available here, so nothing was compiled or run; every finding is read from source, with CI's green build and -race tests on three platforms as the counterweight.

Fifty commits to grow a single get into a whole CLI, and the thing that finally trips it is a description with two lines in it. · reviewed at fc24fd7

khvn26 added 3 commits July 27, 2026 22:51
A credential env var names no host, so an auto-discovered flagsmith.json
can point it at any apiUrl — the CLI would send a Master API key
wherever an untrusted checkout asked. Keychain sessions are already safe
(one entry per instance, keyed by API URL); env credentials are the gap.

Every secret-bearing variable gains an optional host-scoped suffix,
required once the URL is not the SaaS default: FLAGSMITH_API_KEY_<HOST>,
and likewise for ACCESS_TOKEN and ENVIRONMENT_KEY (scoped to sdkApiUrl's
host, since that is where an SDK key is sent). FLAGSMITH_ENVIRONMENT
needs no scoping — client-side keys are public. A scope covers host and
port over either scheme, and an out-of-scope variable is skipped rather
than fatal: resolution falls through to the keychain, which may hold a
session for that host.

Keying the credential to its host follows gh (GH_TOKEN is github.com-
only, GH_ENTERPRISE_TOKEN requires GH_HOST, GH_TOKEN_<host> is in flight
for this same threat) and npm (_authToken keyed by registry, withheld
outside it), rather than inventing a refuse-or-warn scheme of our own.

Also drops "command-line flags" from the Admin API precedence list: no
flag supplies an Admin credential, only -e for the SDK surface.

Design for Themis review on #43 (context.go:155); implementation follows.

beep boop
Implements 03-authentication.md §6. A credential env var names no host,
and flagsmith.json is discovered by walking up from the working
directory — so an untrusted checkout could name any apiUrl and the CLI
would send a Master API key there. Keychain sessions were already safe
(keyed per instance); env credentials were the gap.

envCredential resolves each secret-bearing variable for the target
surface: the host-scoped form (FLAGSMITH_API_KEY_<HOST>, matched
case-insensitively, host and port with `-` doubled and `.`/`:`
single-underscored, scheme excluded) applies wherever it names the host,
and the unscoped form only when the URL is that surface's default. A
miss returns nothing rather than erroring, so resolution falls through
to the keychain and the usual not-logged-in error stands. The credential
source now carries the exact variable, so auth status names it.

Wired in at every point a secret is read: the Admin chain in
loadCredential, the SDK key in `api --sdk`, the same key where it
doubles as an environment reference, and nudgeInit's "has credentials"
check. The SDK variable scopes to sdkApiUrl's host, which is where that
key is sent.

Test fixtures now export the scoped variable, as a self-hosted user
must — so 51 existing tests exercise the scoped path, and their failing
before the migration is what proves the unscoped form is withheld from
a redirected host. Two fixtures target the SaaS default and keep the
unscoped variable, covering that branch.

Closes Themis review on #43 (context.go:155).

beep boop
…ials

The implementation commit left a verification gap: the scoped path was
exercised for all three variables (the migrated fixtures), but
withholding — the security property — was pinned only for
FLAGSMITH_API_KEY. Worse, nothing pinned that the SDK key scopes to
sdkApiUrl rather than apiUrl, because those hosts are identical in every
existing test (sdkApiUrl inherits a non-default apiUrl), so a swapped
surface would have passed silently.

Adds withholding for FLAGSMITH_ACCESS_TOKEN, and a three-case test that
puts the Admin and SDK surfaces on different hosts: a key scoped to the
SDK host is sent, one scoped to the Admin host is not, and an unscoped
secret is withheld. Verified by mutation — scoping the SDK lookup to
pc.APIURL fails two of the three cases.

beep boop
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants