Skip to content

internal/cmd: Cobra root, App, persistent flags, version - #52

Merged
Ilyes512 merged 6 commits into
mainfrom
feat/GH-14-cmd-root-app-version
Aug 8, 2026
Merged

Ilyes512 merged 6 commits into
mainfrom
feat/GH-14-cmd-root-app-version

Conversation

@Ilyes512

@Ilyes512 Ilyes512 commented Aug 8, 2026

Copy link
Copy Markdown
Member

Closes #14.

The command tree's skeleton — root.go, app.go, version.go — so subsequent command issues add
leaves rather than inventing their own wiring. task lint, task test, and task md:check all
pass.

What landed

root.go builds the root, defines the seven persistent flags, and resolves them once in
PersistentPreRunE. app.go holds the App every command closes over: the output writer, the
handle on the debug log level, and the resolved flag values. version.go owns Version.

The wiring follows Output & Exit Codes § Wiring it in Cobra
exactly: writers and the logger from cmd.OutOrStdout() / cmd.ErrOrStderr(), both Silence*
flags set, and os.Exit only in main.

Three decisions worth reviewing

Writer gained WriteResult. version has an answer and it is one value, not a table. Info
would put it on stderr where $(labelsync version --dont-prettify) cannot see it; a one-row bordered
table would reach stdout by pretending the value is something it is not. The output page already
said this case would get a new product-level method rather than moving Info back to stdout, so
this is that method rather than a new decision. It takes both projections at once — pretty renders
the format string, JSON marshals the record and drops the prose — which keeps every stdout line one
typed object and makes --dont-prettify a choice of phrasing rather than a second output path.

main's error handling is a report() function, not four inline lines. os.Exit cannot be
tested and everything above it can. main_test.go drives it over nil, a plain failure, a carrier
holding a failure, and a silent carrier. The silence guard follows the carrier's Err field rather
than its Code: a carrier holding a real failure still prints even when its code is outcome-shaped.

Invalid flag values return plain errors, not new sentinels. --output yaml, --concurrency 0,
--write-rate -1, and a negative --max-wait are rejected with a plain fmt.Errorf, which
exit.Of maps to 1. The sentinels describe how a run can fail once it is under way; a value
rejected before any work starts is a usage error, and error_kind is a public contract not worth
adding to by reflex. Happy to add one if you disagree — it is three lines plus a doc row and a test
row.

Tests

internal/cmd is driven the way main drives it — build the tree, SetOut/SetErr at buffers,
execute — so a test that cannot see the output is the failure signal for bad wiring.

  • flag defaults and full parsing;
  • --output=json / -o json selects the JSON writer, asserted by parsing what a command actually
    produced;
  • each bad flag value rejected, with exit.Of returning 1;
  • an error wrapping ErrRepoInaccessible keeps its sentinel out through the tree, so KindOf still
    yields repo_inaccessible;
  • an outcome code travels on a silent carrier;
  • SilenceUsage / SilenceErrors asserted as fields and behaviourally — after a failing command
    neither stream carries a usage block or Cobra's own copy of the message;
  • --debug gates slog, on the command's stderr and never on stdout;
  • version in all three renderings, plus a test that .goreleaser.yml and the Dockerfile still
    name github.com/specsnl/labelsync/internal/cmd.Version. That path is a build-file string the
    compiler never checks; a rename would ship every release as dev with nothing failing.

Docs & README

The output page's Cobra section rewritten now that it describes code rather than a plan, plus a new
section for WriteResult. The overview page gained a "How the tree is wired" section, and its
exit-code table said Skipped was 3 — corrected to 4 to match what shipped in #51. The design
plan's § CLI is marked partly landed.

The README was a one-line stub; #13 deferred the exit codes and the --output contract to it. It
now covers the global flags, version, the stdout/stderr split with the NDJSON shapes, and the exit
codes — including that the outcome codes are bits and callers should test them as bits.

Scope

Every box on #14 is ticked. Nothing left unticked.

`labelsync version` has an answer, and it is one value. Info would put it on
stderr, where a shell substitution cannot see it; a one-row bordered table would
reach stdout by pretending the value is something it is not. The output page
already said this case would get a new product-level method rather than moving
Info back to stdout — this is that method.

WriteResult takes both projections at once and gives each audience only its own:
the pretty writer renders the format string, the JSON writer marshals the record
and drops the prose. That keeps the NDJSON invariant intact — every line on
stdout is still one typed object, never a sentence — and it makes
--dont-prettify a choice of phrasing rather than a second output path, since
JSON has no prose in it to strip.

It is deliberately not a general-purpose print: the doc comment and the review
checklist both say to reach for it only when the command's whole answer is one
value. Rows still go through output.Table, narration still goes through Info.

The Sprintf is assigned to a variable rather than nested in the Fprintln call.
The nested form is what fmt.Fprintf exists for and staticcheck says so, but
Fprintf is not on the errcheck exclusion list and output is best-effort.
The skeleton every later command hangs off, so subsequent commands add leaves
rather than inventing their own wiring.

root.go builds the root, defines the seven persistent flags, and resolves them
once in PersistentPreRunE. app.go holds the App those commands close over: the
output writer, the handle on the debug log level, and the resolved flag values.
version.go owns Version — the variable .goreleaser.yml and the Dockerfile inject
by full path, which is why it has to keep exactly that name in exactly this
package, and why version_test.go asserts both build files still name it. That
path is a string the compiler never checks; a rename would ship every release as
"dev" with nothing failing.

The wiring is the part worth reviewing, and all of it is in
docs/.../architecture/output.md § Wiring it in Cobra:

  - Writers and the logger come from cmd.OutOrStdout()/cmd.ErrOrStderr(), never
    the NewDefault* constructors. In production they resolve to the same files;
    under test they are the buffers, which is the only reason a command test can
    assert on output at all. NewApp still builds a default writer, for the
    window before flags are parsed where a parse failure needs somewhere to go.
  - An unrecognised --output is wired as pretty before it is rejected. The
    rejection has to have somewhere to go too.
  - SilenceUsage, or every runtime failure drags the usage block along behind
    it. SilenceErrors, or Cobra prints its own copy of the message in addition
    to main's, and its copy carries no error_kind.
  - os.Exit lives in main and nowhere else: from inside a command it would skip
    deferred cleanup and leak temp files, unreleased locks, and unflushed
    writers. Commands return errors; exit.Of turns them into codes.

main's error handling moved into a report() function, because os.Exit cannot be
tested and everything above it can. The silence guard follows the carrier's Err
field, not its Code: a carrier holding a real failure prints even when its code
is outcome-shaped, and only a nil Err means the non-zero code was itself the
answer.

Invalid flag values return plain errors rather than new sentinels. The sentinels
describe how a run can fail once it is under way; a value rejected before any
work starts is a usage error, and adding to the error_kind contract is not
something to do by reflex.

Docs: the CLI wiring section of the output page rewritten now that it describes
code rather than a plan, a new section for WriteResult, the overview page given
a "How the tree is wired" section, and the design plan marked partly landed. The
overview's exit-code table still said Skipped was 3; corrected to 4 to match
what shipped. README written out from its one-line stub — global flags, the
version command, the output contract, and the exit codes, including that the
outcome codes are bits and callers should test them as bits.
@Ilyes512

Ilyes512 commented Aug 8, 2026 •

Copy link
Copy Markdown
Member Author

Pushed 0c8b749: labelsync --version now means exactly labelsync version --dont-prettify.

Both spellings call the same writeVersion, so they cannot drift into disagreeing about what the
version looks like.

The flag is hand-rolled rather than Cobra's built-in cmd.Version + SetVersionTemplate, which is
how specs-cli does it. Cobra handles that flag inside execute(), before PersistentPreRunE:
at that point --output has not been read and app.Out is still the pre-parse fallback writer, so
--output=json --version would print a bare line into what is supposed to be a stream of typed JSON
objects, on os.Stdout where a test could not see it. Routing it through the root's RunE gets it
the writer the user asked for.

It is a local flag, not a persistent one — it answers for the binary, and labelsync sync --version
is not a question sync should have an opinion about. That is where Cobra puts its own too.

Giving the root a RunE changes what a bare labelsync runs, so two tests pin what must not change
with it: no flag still prints the help, and an unknown subcommand still fails rather than quietly
showing it.

On the version string itself

The bare SHA is not a bug, and the recipe is identical to specs-cli's — the difference is entirely
that specs-cli has tags. task build runs git describe --tags --always --dirty=-dev; with no
tags in the tree there is nothing to describe, so --always falls back to the abbreviated commit.
The first v0.0.1 tag here turns it into v0.0.1-31-g69fca8f.

Documented in the README so the next person does not read a SHA as a broken build:

Build Version string
A released binary 1.2.3 — the release tag
task build, on a tag v1.2.3
task build, past a tag v1.2.3-31-g69fca8f
task build, no tags yet 69fca8f — the commit
go build with no ldflags dev

Worth noting the one asymmetry, inherited from specs-cli and not changed here: goreleaser injects
{{ .Version }}, which is the tag with the leading v stripped, so a released binary says 1.2.3
while a local task build on that same tag says v1.2.3. Easy to align in either direction if you
want it — say which.

@Ilyes512

Ilyes512 commented Aug 8, 2026 •

Copy link
Copy Markdown
Member Author

Two more pushes, both about where things are written down rather than what the code does.

27a141d — task build now strips the leading v, so a local build and a release spell a
version the same way. Also removes the mentions of the sibling CLI from the code comments and thins
them out in the architecture docs; the reasoning stands on its own without naming where the defect
was seen, and the provenance stays in AGENTS.md and the design plan.

The one subtlety in the build recipe: the fallback is captured before the strip. Piping git into
the strip would replace git's exit status with the strip's, so || echo "dev" would never fire and a
tree git cannot describe would build with an empty version. A binary that reports nothing is
worse than one that reports dev.

52e5245 — the README had grown a global-flag table, the config search order, the NDJSON shapes,
the exit-code contract, and a table of version strings. That is reference material a reader has to
scroll past to find out whether labelsync is the thing they want, and it duplicates docs that now
have to be kept in step with it.

  • docs/content/docs/usage/ is new: commands, global flags, where the config file is found.
    Deliberately outside the architecture section, which says on its first line that it holds internal
    design documents — a flag table is not one.
  • architecture/versioning.md is new: the linker-injected variable, what each build produces,
    and why the local recipe strips the v and orders its fallback the way it does. Its own page
    rather than a paragraph elsewhere, because it is about the build rather than the code.
  • The README keeps a description, the status, install, a table of where to read more, and how to run
    the checks.

AGENTS.md's convention is updated to match, since that is what a change gets held to at review:
user-facing changes go to docs/content/docs/usage/, and the README is touched only when what
labelsync is, or how it is installed, changes.


Rebased twice: the two README commits are now 52e5245, and the doc cleanup that 27a141d used to carry is its own commit, 5b38e0e. Tree unchanged.

@Ilyes512
Ilyes512 force-pushed the feat/GH-14-cmd-root-app-version branch from 9dece1c to 333e123 Compare August 8, 2026 14:35
Both go through writeVersion, so the two spellings cannot drift into disagreeing
about what the version looks like.

The flag is hand-rolled rather than enabled with Cobra's built-in cmd.Version +
SetVersionTemplate, because Cobra handles that flag inside execute(), before
PersistentPreRunE: at that point --output has not been read and app.Out is still
the pre-parse fallback writer, so `--output=json --version` would print a bare
line into a stream that is supposed to be typed JSON objects, on os.Stdout where
a test could not see it. Routing it through the root's RunE gets it the writer
the user asked for.

It is a local flag, not a persistent one. It answers for the binary, and
`labelsync sync --version` is not a question sync should have an opinion about —
which is also where Cobra puts its own.

Giving the root a RunE changes what a bare `labelsync` runs, so two tests pin
the behaviour that must not change with it: no flag still prints the help, and
an unknown subcommand still fails rather than quietly showing it.
The Output page cited a sibling codebase by name five times: once for the
Info-on-stdout divergence, and four times in the pitfalls section. The reasoning
is what those passages are for, and every one of them stands on its own without
the attribution — a default log level that nothing happens to use is a defect
whoever wrote it.

Naming it also dates badly. A reader who cannot see that repository learns
nothing from the name, and a defect fixed over there quietly turns this page
into a false claim about someone else's code.

The provenance itself is worth keeping, and it stays where it belongs: one line
in AGENTS.md and the comparisons in the design plan, which is explicitly a
document about what this design borrows and from where.

Drops the link to the sibling repository's issue tracker with it. The receipt is
worth less than a page that reads as being about labelsync.
goreleaser injects {{ .Version }}, the tag with the leading v already stripped,
so a released binary said 1.2.3 while `task build` on that same tag said v1.2.3.
Two spellings of one version, and no way to tell from the string which build
produced it.

`task build` now strips the v too. The fallback is captured before the strip
rather than after, because piping git through the strip would swallow its exit
status and hand the build an empty version instead of "dev" — a build that
reports nothing at all is worse than one that reports the wrong thing. Nothing
else needs guarding: a describe fallback is a hex SHA, which cannot start with a
v.
A README should say what the tool is, how to install it, and where the
documentation is. This one had grown a global-flag table, the config search
order, the NDJSON shapes, the exit-code contract, and a table of what version
string each build produces — reference material that a reader has to scroll past
to find out whether labelsync is the thing they want, and that now has to be
kept in step with the docs it duplicates.

Two destinations. docs/content/docs/usage/ is new, and is where the user-facing
reference goes: the commands, the global flags, and where the config file is
found. It is deliberately outside the architecture section, which says on its
first line that it holds internal design documents — a flag table is not one.
The versioning table gets its own architecture page rather than a paragraph in
an existing one, because it is about the build rather than the code: the
injected variable, what each build produces, and why the local recipe strips the
leading v and captures its fallback before doing so.

What is left is a description, a status notice, install, a table of where to
read more, and how to run the checks. The notice says only that this is a work
in progress — the version that listed which subsystems had landed was a second
changelog to keep honest, stale the moment a command lands without someone
remembering to edit it. #47 carries the box to drop it once a release exists.

AGENTS.md's convention is updated to match, since it is what a change gets held
to at review: user-facing changes go to docs/content/docs/usage/, and the README
is touched only when what labelsync is or how it is installed changes.
@Ilyes512
Ilyes512 force-pushed the feat/GH-14-cmd-root-app-version branch from 333e123 to 52e5245 Compare August 8, 2026 14:43
@Ilyes512
Ilyes512 merged commit f776862 into main Aug 8, 2026
5 checks passed
@Ilyes512
Ilyes512 deleted the feat/GH-14-cmd-root-app-version branch August 8, 2026 14:48
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.

internal/cmd: Cobra root, App, persistent flags, version

1 participant