Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0c69414
chore(gonsole): scaffold the module
SirLouen Sep 23, 2026
f4cf1a2
feat(gonsole): read a command line and run the command it names
SirLouen Sep 23, 2026
8b7a795
feat(gonsole): print the command list and a help page per command
SirLouen Sep 23, 2026
3701b96
feat(gonsole): keep writes a dry run until -yes and answer -json
SirLouen Sep 23, 2026
702f88b
feat(gonsole): check and record the account a write acts as
SirLouen Sep 23, 2026
34c7199
feat(gonsole): read settings under one prefix with bounds and plain e…
SirLouen Sep 23, 2026
959d2d2
feat(gonsole): add the version, serve, migrate and seed words
SirLouen Sep 23, 2026
18379d5
refactor(gonsole): say command instead of word in names and docblocks
SirLouen Sep 23, 2026
170552f
feat(gonsole): check command names and recover a crashing command
SirLouen Sep 23, 2026
1fe9db3
feat(gonsole): serve HTTP until a signal ends the run
SirLouen Sep 24, 2026
76b770a
feat(gonsole): record an applied write even after a signal
SirLouen Sep 24, 2026
3f5b307
feat(gonsole): collect the commands each plugin provides
SirLouen Sep 24, 2026
75fbe75
feat(gonsole): register the plugins once per run and release them
SirLouen Sep 24, 2026
ea77cb6
feat(gonsole): run the commands of registered plugins
SirLouen Sep 24, 2026
7a4bb28
feat(gonsole): list the commands of registered plugins
SirLouen Sep 24, 2026
39d1e91
feat(gonsole): migrate, seed and check the registered plugins
SirLouen Sep 24, 2026
5438bc7
test(gonsole): use one constant for the dry-run notice
SirLouen Sep 24, 2026
cf7ce8a
feat(gonsole): hand each call the flags its line set
SirLouen Sep 24, 2026
410fcf7
feat(gonsole): add a testkit for programs built on gonsole
SirLouen Sep 24, 2026
f3c8662
ci: test, lint and scan the gonsole module
SirLouen Sep 24, 2026
6f8b6a1
docs(gonsole): list the module and its unreleased changes
SirLouen Sep 24, 2026
b14e9b6
test(gottext): use a generic product name in the error fixture
SirLouen Sep 24, 2026
8ed7778
test(gonsole): wait for a ready line before the first signal
SirLouen Sep 24, 2026
a1ff707
test(gonsole): count one imported report per line of input
SirLouen Sep 24, 2026
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
18 changes: 12 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["mailkit"]
module: ["mailkit", "gonsole"]
defaults:
run:
working-directory: ${{ matrix.module }}
Expand All @@ -25,7 +25,9 @@ jobs:
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: ${{ matrix.module }}/go.mod
cache-dependency-path: ${{ matrix.module }}/go.sum
cache-dependency-path: |
${{ matrix.module }}/go.mod
${{ matrix.module }}/go.sum
- run: go test -race -covermode=atomic -coverprofile=cover.out ./...
- run: go vet ./...

Expand Down Expand Up @@ -59,15 +61,17 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["mailkit"]
module: ["mailkit", "gonsole"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: ${{ matrix.module }}/go.mod
cache-dependency-path: ${{ matrix.module }}/go.sum
cache-dependency-path: |
${{ matrix.module }}/go.mod
${{ matrix.module }}/go.sum
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.13.2
Expand All @@ -79,7 +83,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["mailkit"]
module: ["mailkit", "gonsole"]
defaults:
run:
working-directory: ${{ matrix.module }}
Expand All @@ -90,5 +94,7 @@ jobs:
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: ${{ matrix.module }}/go.mod
cache-dependency-path: ${{ matrix.module }}/go.sum
cache-dependency-path: |
${{ matrix.module }}/go.mod
${{ matrix.module }}/go.sum
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@ need and ignore the rest.

## Modules

- [`gonsole`](gonsole/) runs the command line of a Go program built from
core commands, settings and compiled plugins.
- [`gottext`](gottext/) reads, writes and syncs gettext catalogs for
TypeScript applications, published to npm as `@gopherium/gottext`.
- [`mailkit`](mailkit/) renders mail from template files and sends it
over SMTP.

## Design

One repository, one self-contained brick per directory, no shared code
between them. A Go brick carries its own go.mod and a TypeScript brick
its own package.json, each with its own CHANGELOG and lint
configuration, released independently under a path-prefixed tag such
as `mailkit/v0.1.0` or `gottext/v0.4.0`. Bricks depend on published
versions only, never on sibling source, so what you pin is what you
get.
One repository, one self-contained brick per directory. A Go brick
carries its own go.mod and a TypeScript brick its own package.json,
each with its own CHANGELOG and lint configuration, released
independently under a path-prefixed tag such as `mailkit/v0.1.0` or
`gottext/v0.4.0`. Bricks share code only through those published tags,
never through sibling source, so what you pin is what you get.

## Reporting security issues

Expand Down
49 changes: 49 additions & 0 deletions gonsole/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
version: "2"

linters:
default: standard
enable:
- cyclop
- depguard
- gocognit
- lll
- misspell
- revive
- unconvert
- unparam
settings:
cyclop:
max-complexity: 10
gocognit:
min-complexity: 15
lll:
line-length: 120
revive:
rules:
- name: blank-imports
disabled: true
- name: exported
depguard:
rules:
engine-purity:
list-mode: strict
files:
- "**/*.go"
allow:
- $gostd
- github.com/gopherium/framework/gonsole
exclusions:
rules:
- path: _test\.go
linters:
- cyclop
- gocognit

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/gopherium/framework
28 changes: 28 additions & 0 deletions gonsole/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Changelog

All notable changes to the `gonsole` module are documented in this
file. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
module follows [Semantic Versioning](https://semver.org/). While at
v0.x, minor releases may contain breaking changes.

Releases of this module are tagged `gonsole/vX.Y.Z`.

## [Unreleased]

### Added

- `Program`, `Main` and `Run`, running a command line and answering exit code 0, 1 or 2.
- `Command`, `Call` and `Step`, a command, what one run of it receives, and a named schema step.
- `Misuse` and `ErrMisused`, marking an error the program answers with exit 2.
- Command names alone or as `namespace:command`, a help page for each, and a listing of them all.
- `-yes` dry runs for commands that write and `-json` for commands that answer one document.
- `-as` with `Authorize` and `Record`, checking and recording the account that acts.
- `Call.Flags`, the command's own flags the line set, for the audit record.
- The base commands `help`, `list`, `version`, `check`, `serve`, `migrate` and `seed`.
- `Program.Check`, refusing every naming offence in the program and its plugins.
- `Renamed`, keeping an old two word spelling working with a warning.
- `Env` with `Required`, `Duration`, `Count`, `Flag`, `Within`, `Parse` and `Timeouts`.
- `NewServer` and `Serve`, serving HTTP until a signal ends the run.
- `Program.Plugins`, `Call.Plugins`, `Loaded`, `Provider` and `Walk`, for compiled plugins' commands.
- `testkit`, running programs from tests in process and as built binaries.
81 changes: 81 additions & 0 deletions gonsole/actor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: Apache-2.0

package gonsole

import (
"context"
"fmt"
"runtime/debug"
)

// perform authorizes the acting account of call, migrates when cmd asks, runs cmd and records the run when it applied.
func (r *runner) perform(ctx context.Context, cmd Command, call Call) error {
if err := r.authorize(ctx, cmd, call); err != nil {
return err
}
if cmd.Migrates && call.Apply {
if err := r.migrate(ctx, call, call.Stderr); err != nil {
return err
}
}
if err := cmd.Run(ctx, call); err != nil {
return err
}
if !call.Apply {
r.warn("dry run, nothing changed, pass -yes to apply")
return nil
}
return r.record(ctx, cmd, call)
}

// authorize refuses the acting account of call when it lacks the capability cmd names.
func (r *runner) authorize(ctx context.Context, cmd Command, call Call) error {
if cmd.Capability == "" {
return nil
}
return r.program.Authorize(ctx, call, cmd.Capability)
}

// record stores the applied run of cmd when cmd names a capability, under a context the end of the run cannot cancel.
func (r *runner) record(ctx context.Context, cmd Command, call Call) error {
if cmd.Capability == "" {
return nil
}
return r.program.Record(context.WithoutCancel(ctx), call, cmd.Name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Audit recording lacks deadline

If the audit store waits for its context to finish, an applied command can remain blocked after the request deadline expires. context.WithoutCancel removes that deadline without replacing it. This non-blocking concern can leave an operator waiting indefinitely when audit storage does not respond.

Artifacts

Go source for the bounded applied-command audit test

  • This source runs an applied command with context-waiting and eventually completing Record callbacks under an independent harness timeout, so a blocked callback cannot stall the test process.

Command used to run the audit test against both revisions

  • This command runs the same Go source against a temporary pre-change checkout and the PR candidate, capturing their output side by side.

Audit test output before the context change

  • The executed pre-change command gave Record a deadline and returned when the request expired, establishing the baseline.

Audit test output with the PR candidate

  • The executed candidate gave Record no deadline or cancellation channel and remained blocked at the independent bound when the callback waited on context, confirming the unbounded-wait defect.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: gonsole/actor.go
Line: 44

Comment:
**Audit recording lacks deadline**

If the audit store waits for its context to finish, an applied command can remain blocked after the request deadline expires. `context.WithoutCancel` removes that deadline without replacing it. This non-blocking concern can leave an operator waiting indefinitely when audit storage does not respond.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

}

// panicked is a panic recovered from the run of one command.
type panicked struct {
command string
value any
stack []byte
}

// Error returns the line naming the command and the panic value.
func (p panicked) Error() string {
return fmt.Sprintf("%s: panic: %v", p.command, p.value)
}

// crashes returns every panic err holds, in the order its message names them.
func crashes(err error) []panicked {
switch e := err.(type) {
case panicked:
return []panicked{e}
case interface{ Unwrap() []error }:
var all []panicked
for _, inner := range e.Unwrap() {
all = append(all, crashes(inner)...)
}
return all
case interface{ Unwrap() error }:
return crashes(e.Unwrap())
}
return nil
}

// recoverRun turns a panic in the run of the command called name into the error err points at.
func recoverRun(name string, err *error) {
if value := recover(); value != nil {
*err = panicked{command: name, value: value, stack: debug.Stack()}
}
}
Loading
Loading