Skip to content
Merged

Dev #22

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
77 changes: 77 additions & 0 deletions .claude/commands/add-native-task.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
Add a new native task handler to this project. Arguments: $ARGUMENTS

The first token of the arguments is the task slug (e.g. `collect-logs`). Everything after it is the human-readable description. If no arguments are provided, ask the user for the slug and description before proceeding.

## Pattern Reference

Native task handlers live in `internal/service/task/native/`. Each handler is one file in that package. Adding a new file is the **only** change required — no other files need to be modified.

**Key files to understand the pattern:**
- `internal/service/task/native/registry.go` — defines `Handler` and `PostRunner` interfaces
- `internal/service/task/native/sync_inventory.go` — cleanest minimal example
- `internal/service/task/native/sync_software.go` — example that calls existing services

**Interfaces (defined in `registry.go`):**
```go
// Required — every handler must implement both methods
type Handler interface {
Slugs() []string
Run(ctx context.Context, cfg *config.Config, task taskstore.Task) (string, error)
}

// Optional — implement this to auto-run other handlers after success
type PostRunner interface {
PostRun() []string // return slugs to run after Run() succeeds
}
```

**Self-registration:** `func init() { Register(&myHandler{}) }` — runs automatically at startup. No central list to update.

## Steps

1. **Read** `internal/service/task/native/sync_inventory.go` to see the exact file structure.

2. **Derive the filename** from the slug: replace every `-` with `_`, append `.go`.
- `collect-logs` → `collect_logs.go`
- `sync-software` → `sync_software.go`

3. **Derive the struct name** from the slug: convert to UpperCamelCase and append `Handler`.
- `collect-logs` → `CollectLogsHandler` (but keep it unexported: `collectLogsHandler`)

4. **Create** `internal/service/task/native/<filename>` with this structure:
```go
package native

import (
"context"
"fmt"

"sentinelgo/internal/config"
"sentinelgo/internal/taskstore"
)

type <camelSlug>Handler struct{}

func init() { Register(&<camelSlug>Handler{}) }

func (h *<camelSlug>Handler) Slugs() []string {
return []string{"<slug>"}
}

// <description>
func (h *<camelSlug>Handler) Run(ctx context.Context, cfg *config.Config, _ taskstore.Task) (string, error) {
// TODO: implement
return "", fmt.Errorf("<slug>: not implemented")
}
```

5. **Ask the user:** "Should this handler automatically trigger any post-run tasks after it succeeds (e.g. `sync-inventory`)?" If yes, add:
```go
func (h *<camelSlug>Handler) PostRun() []string {
return []string{"<post-run-slug>"}
}
```

6. **Run** `go build ./internal/service/task/native/...` to confirm the new file compiles without errors.

7. **Tell the user** the scaffold is ready and point them to implement the `Run` body. Reference `internal/service/task/native/sync_software.go` as an example of a handler that opens a local store and calls an RPC service, or `internal/service/task/native/sync_inventory.go` for one that collects system info. Both show the correct pattern for using `cfg *config.Config` to access Supabase credentials and config paths.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright [2026] [Brain Station 23 PLC.]

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ endif
export CGO_ENABLED=0

# Targets
.PHONY: build clean clean-all all windows linux macos release sign version test-version deps test coverage coverage-html pre-release quality-check format-check setup packages check-no-cgo verify-cross
.PHONY: build clean clean-all all windows linux macos release sign version test-version deps test coverage coverage-html pre-release quality-check format format-check setup packages check-no-cgo verify-cross

all: windows linux macos

Expand Down Expand Up @@ -92,7 +92,7 @@ clean:
rm -rf build/

# Development build (current platform only)
build:
build: format
go build $(LDFLAGS) -o $(DEV_BIN) ./cmd/sentinelgo
@echo "Built $(DEV_BIN) (version $(VERSION))"

Expand Down Expand Up @@ -166,6 +166,10 @@ quality-check:
@command -v golangci-lint >/dev/null 2>&1 || (echo "📦 Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
@export PATH=$$PATH:$$(go env GOPATH)/bin && golangci-lint run

# Auto-format all Go source files
format:
gofmt -s -w .

# Code format check
format-check:
@echo "📝 Checking code formatting..."
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ A single, dependency-free binary that turns every Windows, macOS, and Linux devi
[![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-blue)](#runs-everywhere-your-fleet-does)
[![Build](https://img.shields.io/badge/build-CGO__free%20static%20binary-success)](#why-teams-choose-sentinelgo)
[![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8)](#build-from-source)
[![License](https://img.shields.io/badge/license-MIT-green)](#license)
[![License](https://img.shields.io/badge/license-Apache%202.0-green)](#license)

[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=coverage)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=bugs)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)
[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)
[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=BrainStation-23_SentinelGo&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=BrainStation-23_SentinelGo)

</div>

Expand Down Expand Up @@ -193,4 +202,4 @@ All builds are `CGO_ENABLED=0` static binaries cross-compiled from a single host

## License

MIT
Apache 2.0
108 changes: 93 additions & 15 deletions internal/osinfo/display/display_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,29 @@ func getDisplays() []shared.Display {
gpuVendorRaw, _ := gm["spdisplays_vendor"].(string)
gpuVendor := cleanMacVendorName(gpuVendorRaw)

items, hasItems := gm["_items"].([]any)
// macOS 13+: display items are nested under "spdisplays_ndrvs".
// Older format used "_items".
items, hasItems := gm["spdisplays_ndrvs"].([]any)
if !hasItems {
// Legacy format: entry itself is the display when it has resolution data.
if _, hasRes := gm["spdisplays_resolution"]; hasRes {
if d, ok := buildMacDisplay(gm, gpuVendor); ok {
items, hasItems = gm["_items"].([]any)
}
if hasItems {
for _, item := range items {
dm, ok := item.(map[string]any)
if !ok {
continue
}
if d, ok := buildMacDisplay(dm, gpuVendor); ok {
displays = append(displays, d)
}
}
continue
}
for _, item := range items {
dm, ok := item.(map[string]any)
if !ok {
continue
}
if d, ok := buildMacDisplay(dm, gpuVendor); ok {
// Legacy format: entry itself is the display when it has resolution data.
_, hasNewRes := gm["_spdisplays_resolution"]
_, hasOldRes := gm["spdisplays_resolution"]
if hasNewRes || hasOldRes {
if d, ok := buildMacDisplay(gm, gpuVendor); ok {
displays = append(displays, d)
}
}
Expand All @@ -79,10 +86,14 @@ func buildMacDisplay(dm map[string]any, gpuVendor string) (shared.Display, bool)
d.Manufacturer = gpuVendor
}

if v, ok := dm["spdisplays_serial_number"].(string); ok && v != "" {
// Serial number: modern key is "_spdisplays_display-serial-number"
if v, ok := dm["_spdisplays_display-serial-number"].(string); ok && v != "" {
d.SerialNumber = v
} else if v, ok := dm["spdisplays_serial_number"].(string); ok && v != "" {
d.SerialNumber = v
}

// Physical size in inches
if v, ok := dm["spdisplays_inches"].(string); ok && v != "" {
if sz, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && sz > 0 {
d.Size = sz
Expand All @@ -94,22 +105,55 @@ func buildMacDisplay(dm map[string]any, gpuVendor string) (shared.Display, bool)
}
}

if v, ok := dm["spdisplays_resolution"].(string); ok && v != "" {
// Native (physical) pixel resolution, e.g. "_spdisplays_pixels": "3024 x 1964"
if v, ok := dm["_spdisplays_pixels"].(string); ok && v != "" {
if res, _ := parseResolutionAndRefreshRate(v); res != "" {
d.Resolution = res
}
}

// Refresh rate from "_spdisplays_resolution"; also provides logical resolution
// as a fallback when native pixels were not available.
if v, ok := dm["_spdisplays_resolution"].(string); ok && v != "" {
logicalRes, hz := parseResolutionAndRefreshRate(v)
d.RefreshRate = hz
if d.Resolution == "" {
d.Resolution = logicalRes
}
} else if v, ok := dm["spdisplays_resolution"].(string); ok && v != "" {
res, hz := parseResolutionAndRefreshRate(v)
d.Resolution = res
if d.Resolution == "" {
d.Resolution = res
}
d.RefreshRate = hz
}

if v, ok := dm["spdisplays_connection_type"].(string); ok && v != "" {
d.ConnectionType = macDisplayConnectionType(v)
}

// Year: present for external displays; 0 for built-in panels (skip those)
if v, ok := dm["_spdisplays_display-year"].(string); ok && v != "" {
if yr, err := strconv.Atoi(v); err == nil && yr > 1990 {
d.Year = yr
}
}

// Monitor type tags derived from spdisplays_display_type
if v, ok := dm["spdisplays_display_type"].(string); ok && v != "" {
d.MonitorType = parseMacDisplayType(v)
}

return d, true
}

// cleanMacVendorName strips the PCI-ID suffix from strings like "Apple (0x106b)"
// and returns the bare vendor name. Returns the input unchanged if no " (" is found.
// cleanMacVendorName normalises vendor strings from system_profiler:
// - "sppci_vendor_Apple" → "Apple"
// - "Apple (0x106b)" → "Apple"
func cleanMacVendorName(s string) string {
if strings.HasPrefix(s, "sppci_vendor_") {
return s[len("sppci_vendor_"):]
}
if idx := strings.Index(s, " ("); idx > 0 {
name := strings.TrimSpace(s[:idx])
if strings.EqualFold(name, "unknown") {
Expand All @@ -120,6 +164,40 @@ func cleanMacVendorName(s string) string {
return s
}

// parseMacDisplayType converts a spdisplays_display_type token like
// "spdisplays_built-in-liquid-retina-xdr" into human-readable tags.
func parseMacDisplayType(s string) []string {
s = strings.TrimPrefix(strings.ToLower(s), "spdisplays_")
if s == "" {
return nil
}
var tags []string
if strings.Contains(s, "built-in") {
tags = append(tags, "Built-In")
} else if strings.Contains(s, "external") {
tags = append(tags, "External")
}
switch {
case strings.Contains(s, "liquid-retina-xdr"):
tags = append(tags, "Liquid Retina XDR")
case strings.Contains(s, "retina-xdr"):
tags = append(tags, "Retina XDR")
case strings.Contains(s, "retina"):
tags = append(tags, "Retina")
}
if len(tags) == 0 {
parts := strings.Split(s, "-")
var words []string
for _, p := range parts {
if p != "" {
words = append(words, strings.ToUpper(p[:1])+p[1:])
}
}
return []string{strings.Join(words, " ")}
}
return tags
}

// parseResolutionAndRefreshRate parses strings like "2560 x 1440 @ 60.00Hz" or
// "3456 x 2160 Retina" into a compact "WxH" resolution and Hz value.
func parseResolutionAndRefreshRate(s string) (resolution string, refreshRate float64) {
Expand Down
73 changes: 71 additions & 2 deletions internal/osinfo/display/display_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ func TestCleanMacVendorName(t *testing.T) {
{"Unknown (0x1234)", ""},
{"", ""},
{"Dell Inc.", "Dell Inc."},
// sppci_vendor_ prefix (modern format)
{"sppci_vendor_Apple", "Apple"},
{"sppci_vendor_AMD", "AMD"},
{"sppci_vendor_Intel", "Intel"},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
Expand Down Expand Up @@ -81,6 +85,71 @@ func TestMacDisplayConnectionType(t *testing.T) {
}
}

func TestParseMacDisplayType(t *testing.T) {
tests := []struct {
input string
want []string
}{
{"spdisplays_built-in-liquid-retina-xdr", []string{"Built-In", "Liquid Retina XDR"}},
{"spdisplays_built-in-retina-xdr", []string{"Built-In", "Retina XDR"}},
{"spdisplays_built-in-retina", []string{"Built-In", "Retina"}},
{"spdisplays_built-in", []string{"Built-In"}},
{"spdisplays_external", []string{"External"}},
{"", nil},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got := parseMacDisplayType(tc.input)
if len(got) != len(tc.want) {
t.Fatalf("parseMacDisplayType(%q) = %v, want %v", tc.input, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("parseMacDisplayType(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i])
}
}
})
}
}

func TestBuildMacDisplay_ModernFormat(t *testing.T) {
// Mirrors the real system_profiler output structure for an Apple M2 Pro built-in display
dm := map[string]any{
"_name": "Color LCD",
"_spdisplays_display-serial-number": "fd626d62",
"_spdisplays_display-year": "0",
"_spdisplays_pixels": "3024 x 1964",
"_spdisplays_resolution": "1512 x 982 @ 120.00Hz",
"spdisplays_connection_type": "spdisplays_internal",
"spdisplays_display_type": "spdisplays_built-in-liquid-retina-xdr",
}
d, ok := buildMacDisplay(dm, "Apple")
if !ok {
t.Fatal("buildMacDisplay returned ok=false")
}
if d.Resolution != "3024x1964" {
t.Errorf("Resolution = %q, want %q", d.Resolution, "3024x1964")
}
if d.RefreshRate != 120.0 {
t.Errorf("RefreshRate = %v, want 120.0", d.RefreshRate)
}
if d.SerialNumber != "fd626d62" {
t.Errorf("SerialNumber = %q, want %q", d.SerialNumber, "fd626d62")
}
if d.ConnectionType != "Internal" {
t.Errorf("ConnectionType = %q, want %q", d.ConnectionType, "Internal")
}
if d.Manufacturer != "Apple" {
t.Errorf("Manufacturer = %q, want %q", d.Manufacturer, "Apple")
}
if len(d.MonitorType) == 0 {
t.Error("MonitorType is empty, want at least one tag")
}
if d.Year != 0 {
t.Errorf("Year = %d, want 0 (built-in has year=0, should be skipped)", d.Year)
}
}

func TestGetDisplays_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
Expand All @@ -94,9 +163,9 @@ func TestGetDisplays_Integration(t *testing.T) {
if d.Model == "" {
t.Errorf("display[%d] %q has empty Model", i, d.Description)
}
t.Logf("display[%d]: desc=%q mfr=%q model=%q serial=%q size=%.1f\" refresh=%.2fHz conn=%q res=%q",
t.Logf("display[%d]: desc=%q mfr=%q model=%q serial=%q size=%.1f\" refresh=%.2fHz conn=%q res=%q year=%d type=%v",
i, d.Description, d.Manufacturer, d.Model, d.SerialNumber,
d.Size, d.RefreshRate, d.ConnectionType, d.Resolution)
d.Size, d.RefreshRate, d.ConnectionType, d.Resolution, d.Year, d.MonitorType)
}
t.Logf("total displays: %d", len(displays))
}
Loading
Loading