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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ Create a public IP address.
──────────────────────────────────────────────────────────────────────────────────────────────────────────
az network public-ip create \
--name $PIP \
--resource-group '$RG' \
--resource-group $RG \
--allocation-method Static \
--idle-timeout 15 \
--location '$LOC' \
--location $LOC \
--sku StandardV2 \
--tier Regional \
--version IPv4 \
Expand Down Expand Up @@ -130,10 +130,10 @@ Create a public IP address.
──────────────────────────────────────────────────────────────────────────────────────────────────────────
az network public-ip create \
--name $PIP \
--resource-group '$RG' \
--resource-group $RG \
--allocation-method Static \
--idle-timeout 15 \
--location '$LOC' \
--location $LOC \
--sku StandardV2 \
--tier Regional \
--version IPv4 \
Expand Down Expand Up @@ -170,10 +170,10 @@ Create a public IP address.
──────────────────────────────────────────────────────────────────────────────────────────────────────────
az network public-ip create \
--name $PIP \
--resource-group '$RG' \
--resource-group $RG \
--allocation-method Static \
--idle-timeout 15 \
--location '$LOC' \
--location $LOC \
--sku StandardV2 \
--tier Regional \
--version IPv4 \
Expand Down Expand Up @@ -221,10 +221,10 @@ Create a public IP address.
──────────────────────────────────────────────────────────────────────────────────────────────────────────
az network public-ip create \
--name $PIP \
--resource-group '$RG' \
--resource-group $RG \
--allocation-method Static \
--idle-timeout 15 \
--location '$LOC' \
--location $LOC \
--sku StandardV2 \
--tier Regional \
--version IPv4 \
Expand Down
25 changes: 25 additions & 0 deletions cmd/azform/cursor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package main

import (
"testing"

"github.com/someson/azform/internal/shell"
)

func TestCursorByte(t *testing.T) {
line := "echo ééé && az vm list"
// The shell reports 21 characters; the same position is 24 bytes.
if got := cursorByte(line, 21, line); got != len(line) {
t.Errorf("cursorByte = %d, want %d", got, len(line))
}
raw, ok := shell.ParseRaw(line, cursorByte(line, 21, line))
if !ok || raw.CommandPath != "vm list" {
t.Errorf("ParseRaw with prefix cursor: %q, %v", raw.CommandPath, ok)
}
if got := cursorByte(line, 5, ""); got != 5 {
t.Errorf("no prefix: got %d, want 5", got)
}
if got := cursorByte(line, 5, "unrelated"); got != 5 {
t.Errorf("mismatched prefix: got %d, want 5", got)
}
}
4 changes: 2 additions & 2 deletions cmd/azform/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,8 @@ func oneLine(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
s = strings.TrimSpace(s)
if len(s) > 200 {
s = s[:197] + "..."
if r := []rune(s); len(r) > 200 {
s = string(r[:197]) + "..."
}
return s
}
17 changes: 15 additions & 2 deletions cmd/azform/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func run(args []string) int {
outPath string
envOutPath string
cursor int
cursorPrefix string
varsPath string
cwd string
cacheDir string
Expand All @@ -66,7 +67,8 @@ func run(args []string) int {
fs.StringVar(&line, "line", "", "current shell buffer contents")
fs.StringVar(&outPath, "out", "", "file path to write the assembled command")
fs.StringVar(&envOutPath, "env-out", "", "file path to write pending shell-variable exports (g-popup); empty = disabled")
fs.IntVar(&cursor, "cursor", 0, "cursor position in --line")
fs.IntVar(&cursor, "cursor", 0, "cursor position in --line, in bytes")
fs.StringVar(&cursorPrefix, "cursor-prefix", "", "text of --line left of the cursor; overrides --cursor (zsh's CURSOR counts characters, not bytes)")
fs.StringVar(&varsPath, "vars", "", "NUL-separated NAME=VALUE file from the shell widget")
fs.StringVar(&cwd, "cwd", "", "shell working directory (for @ path completion)")
fs.StringVar(&cacheDir, "cache-dir", "", "override metadata cache directory")
Expand Down Expand Up @@ -135,7 +137,7 @@ func run(args []string) int {
}

// Parse the shell buffer to locate the az command.
raw, ok := shell.ParseRaw(line, cursor)
raw, ok := shell.ParseRaw(line, cursorByte(line, cursor, cursorPrefix))
if !ok {
if len(fs.Args()) == 0 {
fs.Usage()
Expand Down Expand Up @@ -258,6 +260,17 @@ func runTUI(raw shell.RawBuffer, shellVars, azureDefaults []vars.Variable, outPa
return 0
}

// cursorByte returns the cursor as a byte offset into line, which is what
// the tokenizer works in. zsh's CURSOR and bash 5's READLINE_POINT count
// characters, so the widgets also pass the text left of the cursor; its
// byte length is exact. A prefix that does not match line is ignored.
func cursorByte(line string, cursor int, prefix string) int {
if prefix != "" && strings.HasPrefix(line, prefix) {
return len(prefix)
}
return cursor
}

func printVersion() {
fmt.Printf("azform %s", version)
if commit != "" {
Expand Down
55 changes: 46 additions & 9 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
# - Print a one-screen summary with the next user action.
#
# --uninstall reverses the above (binary, share dir, profile block). State
# (drafts/bindings) is preserved unless --purge is given.
# (drafts/bindings) and the metadata cache are preserved unless --purge is
# also given (or PURGE_STATE=1 is set).
#
# POSIX sh compatible (dash on Debian).
set -eu

REPO="${AZFORM_REPO:-someson/azform}"
BIN_DIR="${AZFORM_BIN_DIR:-$HOME/.local/bin}"
SHARE_DIR="${AZFORM_SHARE_DIR:-$HOME/.local/share/azform}"
STATE_DIR="${AZFORM_STATE_DIR:-$HOME/.local/state/azform}"
VERSION="${AZFORM_VERSION:-}"

MARKER_BEGIN="# >>> azform >>>"
Expand Down Expand Up @@ -235,11 +235,41 @@ add_to_profile() {
log "added azform block to $prof (backup: $backup)"
}

# state_dir and cache_dir mirror state.DefaultStateDir and
# metadata.DefaultCacheDir, so --purge removes the directories the binary
# really uses (on macOS that is ~/Library/..., not ~/.local/...). Note the
# asymmetry, copied from the Go side: XDG_STATE_HOME wins over the macOS
# default, while the macOS cache default wins over XDG_CACHE_HOME.
state_dir() {
if [ -n "${AZFORM_STATE_DIR:-}" ]; then
echo "$AZFORM_STATE_DIR"
elif [ -n "${XDG_STATE_HOME:-}" ]; then
echo "$XDG_STATE_HOME/azform"
elif [ "$(uname -s)" = Darwin ]; then
echo "$HOME/Library/Application Support/azform"
else
echo "$HOME/.local/state/azform"
fi
}

cache_dir() {
if [ -n "${AZFORM_CACHE_DIR:-}" ]; then
echo "$AZFORM_CACHE_DIR"
elif [ "$(uname -s)" = Darwin ]; then
echo "$HOME/Library/Caches/azform"
elif [ -n "${XDG_CACHE_HOME:-}" ]; then
echo "$XDG_CACHE_HOME/azform"
else
echo "$HOME/.cache/azform"
fi
}

uninstall() {
rm -f "$BIN_DIR/azform"
rm -rf "$SHARE_DIR"
if [ "${PURGE_STATE:-0}" = "1" ]; then
rm -rf "$STATE_DIR"
rm -rf "$(state_dir)" "$(cache_dir)"
log "removed state and metadata cache"
fi
for prof in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"; do
if [ -f "$prof" ] && grep -qF "$MARKER_BEGIN" "$prof"; then
Expand All @@ -263,12 +293,19 @@ if [ "${AZFORM_INSTALL_LIB:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi

case "${1:-}" in
--uninstall)
uninstall
exit 0
;;
esac
UNINSTALL=0
for arg in "$@"; do
case "$arg" in
--uninstall) UNINSTALL=1 ;;
--purge) PURGE_STATE=1 ;;
*) err "unknown argument: $arg" ;;
esac
done
if [ "$UNINSTALL" = 1 ]; then
uninstall
exit 0
fi
[ "${PURGE_STATE:-0}" = "1" ] && err "--purge is only valid with --uninstall"

platform=$(detect_platform)
version=$(resolve_latest_version)
Expand Down
18 changes: 14 additions & 4 deletions internal/debug/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func (l *Logger) SetNow(now func() time.Time) {
// Keys are emitted in alphabetical order for stable diffs. No-op on nil
// receiver.
func (l *Logger) Event(name string, fields map[string]any) {
if l == nil || l.w == nil {
if l == nil {
return
}
e := make(map[string]any, len(fields)+2)
Expand Down Expand Up @@ -84,14 +84,24 @@ func (l *Logger) Event(name string, fields map[string]any) {
}
buf = append(buf, '}', '\n')

// The writer is checked under the lock: background commands (metadata
// resolve and refresh) can still log after main has closed the logger.
l.mu.Lock()
_, _ = l.w.Write(buf)
if l.w != nil {
_, _ = l.w.Write(buf)
}
l.mu.Unlock()
}

// Close flushes and closes the underlying file. Safe on nil; idempotent.
// Close flushes and closes the underlying file. Safe on nil; idempotent;
// safe to call while other goroutines are still logging.
func (l *Logger) Close() error {
if l == nil || l.w == nil {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.w == nil {
return nil
}
err := l.w.Close()
Expand Down
19 changes: 19 additions & 0 deletions internal/debug/debug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,22 @@ var _ io.WriteCloser = nopCloser{bytes.NewBuffer(nil)}
// future debug sinks like a write-through in-memory buffer for tests.)
var _ = nopCloser{}
var _ = bytes.NewBuffer

// Background commands may still log while main closes the logger; run
// with -race to catch unsynchronised access to the writer.
func TestEventConcurrentWithClose(t *testing.T) {
l, err := debug.Open(t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 200; i++ {
l.Event("tick", map[string]any{"i": i})
}
}()
_ = l.Close()
<-done
l.Event("after-close", nil) // must be a no-op, not a panic
}
14 changes: 6 additions & 8 deletions internal/diagnostics/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,14 @@ func rotateIfNeeded(path string) error {
if err != nil {
return fmt.Errorf("diagnostics: read log for rotation: %w", err)
}
n := 1
for _, b := range data {
if b == '\n' {
n++
}
}
if n <= healthMaxRows {
// Count records, not separators: every entry ends in '\n', so a
// full log of healthMaxRows entries holds exactly healthMaxRows
// newlines. The previous count started at 1 and rewrote the whole
// file on every append once the log was full.
lines := splitLines(data)
if len(lines) <= healthMaxRows {
return nil
}
lines := splitLines(data)
keep := lines[len(lines)-healthMaxRows:]
tmp, err := os.CreateTemp(filepath.Dir(path), ".parse-health-*.tmp")
if err != nil {
Expand Down
35 changes: 35 additions & 0 deletions internal/diagnostics/health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,38 @@ func splitLines(data []byte) [][]byte {
}
return out
}

// A full log (exactly 200 entries) is left alone: rotation rewrites the
// file only when an append takes it past the limit.
func TestAppendHealthNoRewriteAtLimit(t *testing.T) {
dir := t.TempDir()
now := time.Now()
for i := 0; i < 199; i++ {
_ = diagnostics.AppendHealth(dir, diagnostics.Entry{Command: "cmd", Params: i, SectionsOK: true}, now)
}
path := filepath.Join(dir, "parse-health.log")
before, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
// The 200th entry fits: rotation (temp file + rename) must not run.
_ = diagnostics.AppendHealth(dir, diagnostics.Entry{Command: "cmd", Params: 199, SectionsOK: true}, now)
at, _ := os.Stat(path)
if !os.SameFile(before, at) {
t.Errorf("log rewritten at exactly 200 entries")
}
before = at
data, _ := os.ReadFile(path)
if n := len(splitLines(data)); n != 200 {
t.Fatalf("log has %d entries, want 200", n)
}
_ = diagnostics.AppendHealth(dir, diagnostics.Entry{Command: "cmd", Params: 1, SectionsOK: true}, now)
after, _ := os.Stat(path)
if os.SameFile(before, after) {
t.Errorf("201st entry should rotate the log")
}
data, _ = os.ReadFile(path)
if n := len(splitLines(data)); n != 200 {
t.Errorf("after rotation: %d entries, want 200", n)
}
}
14 changes: 2 additions & 12 deletions internal/lock/lock.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package lock enforces "one azform per terminal" (spec §15.2). The lock is
// keyed by the controlling tty's (dev, inode) so multiple terminal windows
// stay independent.
// keyed by the terminal's session id (see terminalKey) so multiple terminal
// windows stay independent.
package lock

import (
Expand Down Expand Up @@ -40,13 +40,3 @@ func (l *Lock) Path() string {
}
return l.path
}

// runtimeDir returns the directory where lock files live. Honours
// $XDG_RUNTIME_DIR per the XDG Base Directory Specification; falls back to
// the system temp dir when the env var is unset (per spec §15.2).
func runtimeDir() string {
if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" {
return d
}
return os.TempDir()
}
Loading
Loading