From 8f25db2a67a559ce09e5f05bbe545e06176adb34 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 6 Mar 2026 22:19:51 -0500 Subject: [PATCH 1/4] feat: setup uses cache by default; replace stale field mappings on re-run - Add --use-cache (default true) and --cache-dir to setup command. Purchase sources are read from devices.json instead of fetching all ABM devices. ABM client is skipped entirely when --use-cache is set. - MergeFieldMapping now accepts replaceValues: existing entries whose ABM attribute value is in that set are removed before writing fresh ones, preventing stale field IDs from accumulating across setup runs. - Add GetPurchaseSourcesFromCache to abmclient for cache-based lookup. - Add TestMergeFieldMapping_ReplaceStaleIDs test. Co-Authored-By: Claude Sonnet 4.6 --- abmclient/client.go | 32 +++++++++++++++ cmd/root.go | 4 +- cmd/setup.go | 94 +++++++++++++++++++++++++++++-------------- config/config.go | 19 +++++++-- config/config_test.go | 44 ++++++++++++++++++-- 5 files changed, 154 insertions(+), 39 deletions(-) diff --git a/abmclient/client.go b/abmclient/client.go index 75df527..a87d25e 100644 --- a/abmclient/client.go +++ b/abmclient/client.go @@ -5,8 +5,10 @@ package abmclient import ( "context" + "encoding/json" "fmt" "io" + "os" "time" "github.com/sirupsen/logrus" @@ -236,6 +238,36 @@ func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, e return sources, nil } +// GetPurchaseSourcesFromCache reads devices.json from cacheDir and returns +// the unique purchase sources without making any ABM API calls. +func GetPurchaseSourcesFromCache(cacheDir string) ([]PurchaseSource, error) { + data, err := os.ReadFile(cacheDir + "/devices.json") + if err != nil { + return nil, fmt.Errorf("reading devices cache: %w", err) + } + var devices []Device + if err := json.Unmarshal(data, &devices); err != nil { + return nil, fmt.Errorf("parsing devices cache: %w", err) + } + seen := make(map[PurchaseSource]bool) + var sources []PurchaseSource + for _, d := range devices { + if d.Attributes == nil { + continue + } + srcType := string(d.Attributes.PurchaseSourceType) + if srcType == "" { + continue + } + ps := PurchaseSource{Type: srcType, ID: d.Attributes.PurchaseSourceID} + if !seen[ps] { + seen[ps] = true + sources = append(sources, ps) + } + } + return sources, nil +} + // CoverageResult holds both the "winning" AppleCare record and the full list // of all coverage records for a device. type CoverageResult struct { diff --git a/cmd/root.go b/cmd/root.go index 25b94d5..5898a79 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -207,8 +207,8 @@ func Execute() { cmd.Flags().Bool("dry-run", false, "Simulate without making changes") } - // --cache-dir: download, sync - for _, cmd := range []*cobra.Command{downloadCmd, syncCmd} { + // --cache-dir: download, sync, setup + for _, cmd := range []*cobra.Command{downloadCmd, syncCmd, setupCmd} { cmd.Flags().String("cache-dir", "", `Directory for cached API responses (default ".cache")`) } diff --git a/cmd/setup.go b/cmd/setup.go index dceeaac..75205b0 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -6,24 +6,27 @@ import ( "github.com/spf13/cobra" + "github.com/CampusTech/axm2snipe/abmclient" "github.com/CampusTech/axm2snipe/config" "github.com/CampusTech/axm2snipe/snipe" ) // NewSetupCmd creates the setup command. func NewSetupCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "setup", Short: "Create AXM custom fields in Snipe-IT", Long: "Creates AXM custom fields in Snipe-IT, associates them with the configured fieldset, and saves the resulting field mappings to the config file.", RunE: runSetup, } + cmd.Flags().Bool("use-cache", true, "Use cached devices.json for purchase source discovery instead of fetching from ABM") + return cmd } func runSetup(cmd *cobra.Command, args []string) error { - if err := Cfg.ValidateABM(); err != nil { - return err - } + useCache, _ := cmd.Flags().GetBool("use-cache") + applyBoolFlag(cmd, "use-cache", &Cfg.Sync.UseCache) + if err := Cfg.ValidateSnipeIT(); err != nil { return err } @@ -38,9 +41,17 @@ func runSetup(cmd *cobra.Command, args []string) error { ctx, cancel := contextWithSignal() defer cancel() - abmClient, err := newABMClient(ctx) - if err != nil { - return err + // ABM client is only needed when not using cache (for MDM servers + purchase sources) + var abmClient *abmclient.Client + if !useCache { + if err := Cfg.ValidateABM(); err != nil { + return err + } + var err error + abmClient, err = newABMClient(ctx) + if err != nil { + return err + } } snipeClient, err := newSnipeClient() @@ -49,24 +60,26 @@ func runSetup(cmd *cobra.Command, args []string) error { } // Fetch MDM server names from ABM for the Assigned MDM Server field options - log.Info("Fetching MDM servers from ABM...") - mdmServerNames, err := abmClient.GetMDMServers(ctx) - if err != nil { - log.Warnf("Could not fetch MDM servers: %v (Assigned MDM Server field will be a text field)", err) - } - mdmServerField := snipe.FieldDef{Name: "AXM: Assigned MDM Server", Element: "text", Format: "ANY", HelpText: "MDM server assigned in Apple Business/School Manager"} - if len(mdmServerNames) > 0 { - var names []string - for _, name := range mdmServerNames { - if name != "" { - names = append(names, name) + if useCache { + log.Info("Skipping MDM server fetch (--use-cache); Assigned MDM Server field will be a text field") + } else { + log.Info("Fetching MDM servers from ABM...") + mdmServers, err := abmClient.GetMDMServers(ctx) + if err != nil { + log.Warnf("Could not fetch MDM servers: %v (Assigned MDM Server field will be a text field)", err) + } else { + var names []string + for _, name := range mdmServers { + if name != "" { + names = append(names, name) + } + } + if len(names) > 0 { + mdmServerField.Element = "listbox" + mdmServerField.FieldValues = strings.Join(names, "\n") + log.Infof("Found %d MDM servers: %s", len(names), strings.Join(names, ", ")) } - } - if len(names) > 0 { - mdmServerField.Element = "listbox" - mdmServerField.FieldValues = strings.Join(names, "\n") - log.Infof("Found %d MDM servers: %s", len(names), strings.Join(names, ", ")) } } @@ -111,14 +124,19 @@ func runSetup(cmd *cobra.Command, args []string) error { // Build field mapping: DB column -> ABM attribute fieldMapping := make(map[string]string) + // replaceValues is the set of ABM attribute values managed by setup — + // existing config entries with these values will be replaced so stale + // field IDs from a previous run don't accumulate. + replaceValues := make(map[string]bool) for name, dbCol := range results { if attr, ok := abmAttr[name]; ok { fieldMapping[dbCol] = attr + replaceValues[attr] = true } } - // Save to config file - if err := config.MergeFieldMapping(ConfigFile, fieldMapping); err != nil { + // Save to config file, replacing any stale mappings for attributes we manage + if err := config.MergeFieldMapping(ConfigFile, fieldMapping, replaceValues); err != nil { log.Warnf("Could not save field mappings to %s: %v", ConfigFile, err) fmt.Println("\nAdd these to your settings.yaml field_mapping manually:") for dbCol, attr := range fieldMapping { @@ -137,12 +155,26 @@ func runSetup(cmd *cobra.Command, args []string) error { } } - // Fetch purchase sources from ABM and write supplier_mapping scaffold - log.Info("Fetching purchase sources from ABM (this fetches all devices)...") - purchaseSources, err := abmClient.GetAllPurchaseSources(ctx) - if err != nil { - log.Warnf("Could not fetch purchase sources: %v", err) - } else if len(purchaseSources) > 0 { + // Fetch purchase sources and write supplier_mapping scaffold + var purchaseSources []abmclient.PurchaseSource + cacheDir := Cfg.Sync.CacheDir + if cacheDir == "" { + cacheDir = ".cache" + } + if useCache { + log.Infof("Loading purchase sources from cache (%s/devices.json)...", cacheDir) + purchaseSources, err = abmclient.GetPurchaseSourcesFromCache(cacheDir) + if err != nil { + log.Warnf("Could not load purchase sources from cache: %v", err) + } + } else { + log.Info("Fetching purchase sources from ABM (this fetches all devices)...") + purchaseSources, err = abmClient.GetAllPurchaseSources(ctx) + if err != nil { + log.Warnf("Could not fetch purchase sources: %v", err) + } + } + if len(purchaseSources) > 0 { var entries []config.SupplierEntry for _, ps := range purchaseSources { if ps.Type == "MANUALLY_ADDED" { diff --git a/config/config.go b/config/config.go index 92d7ba5..c1ed043 100644 --- a/config/config.go +++ b/config/config.go @@ -155,9 +155,11 @@ func (c *Config) ValidateSnipeIT() error { } // MergeFieldMapping reads a YAML config file, merges new field mappings into -// sync.field_mapping (without overwriting existing entries), and writes it back. +// sync.field_mapping and writes it back. If replaceValues is non-nil, any +// existing entries whose ABM attribute value is in that set are removed first +// (used by setup to replace stale field IDs with fresh ones). // Comments and structure are preserved via yaml.v3 node API. -func MergeFieldMapping(path string, newMappings map[string]string) error { +func MergeFieldMapping(path string, newMappings map[string]string, replaceValues map[string]bool) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("reading config file: %w", err) @@ -182,7 +184,18 @@ func MergeFieldMapping(path string, newMappings map[string]string) error { // Find or create "field_mapping" mapping under sync fmNode := findOrCreateMapping(syncNode, "field_mapping") - // Build set of existing keys + // Remove stale entries whose value is in replaceValues + if len(replaceValues) > 0 { + var kept []*yaml.Node + for i := 0; i < len(fmNode.Content)-1; i += 2 { + if !replaceValues[fmNode.Content[i+1].Value] { + kept = append(kept, fmNode.Content[i], fmNode.Content[i+1]) + } + } + fmNode.Content = kept + } + + // Build set of remaining keys existing := make(map[string]bool) for i := 0; i < len(fmNode.Content)-1; i += 2 { existing[fmNode.Content[i].Value] = true diff --git a/config/config_test.go b/config/config_test.go index 3fc948c..f273540 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -300,7 +300,7 @@ func TestMergeFieldMapping(t *testing.T) { "_snipeit_status_2": "applecare_status", } - if err := MergeFieldMapping(path, newMappings); err != nil { + if err := MergeFieldMapping(path, newMappings, nil); err != nil { t.Fatal(err) } @@ -329,7 +329,7 @@ func TestMergeFieldMapping_EmptyFile(t *testing.T) { "_snipeit_mac_1": "wifi_mac", } - if err := MergeFieldMapping(path, newMappings); err != nil { + if err := MergeFieldMapping(path, newMappings, nil); err != nil { t.Fatal(err) } @@ -355,7 +355,7 @@ func TestMergeFieldMapping_SkipsEmptyKeys(t *testing.T) { "valid_key": "", // empty value — skip } - if err := MergeFieldMapping(path, newMappings); err != nil { + if err := MergeFieldMapping(path, newMappings, nil); err != nil { t.Fatal(err) } @@ -367,3 +367,41 @@ func TestMergeFieldMapping_SkipsEmptyKeys(t *testing.T) { t.Errorf("should only have 1 mapping, got %d", len(cfg.Sync.FieldMapping)) } } + +func TestMergeFieldMapping_ReplaceStaleIDs(t *testing.T) { + // Simulates setup running a second time: old field ID _snipeit_color_7 should + // be replaced by new _snipeit_color_99 because "color" is in replaceValues. + // The unmanaged entry "manual_key: custom" must be preserved. + dir := t.TempDir() + path := filepath.Join(dir, "settings.yaml") + + initial := `sync: + field_mapping: + _snipeit_color_7: color + manual_key: custom +` + if err := os.WriteFile(path, []byte(initial), 0644); err != nil { + t.Fatal(err) + } + + newMappings := map[string]string{"_snipeit_color_99": "color"} + replaceValues := map[string]bool{"color": true} + + if err := MergeFieldMapping(path, newMappings, replaceValues); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if _, ok := cfg.Sync.FieldMapping["_snipeit_color_7"]; ok { + t.Error("stale field ID should have been removed") + } + if cfg.Sync.FieldMapping["_snipeit_color_99"] != "color" { + t.Error("new field ID should be present") + } + if cfg.Sync.FieldMapping["manual_key"] != "custom" { + t.Error("unmanaged entry should be preserved") + } +} From e86cd727752a92fe54c4f599c7f61724a873f59a Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 6 Mar 2026 22:33:10 -0500 Subject: [PATCH 2/4] fix: use filepath.Join in GetPurchaseSourcesFromCache; add dry-run guard to SetupFields Co-Authored-By: Claude Sonnet 4.6 --- abmclient/client.go | 3 ++- snipe/client.go | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/abmclient/client.go b/abmclient/client.go index a87d25e..87ea8be 100644 --- a/abmclient/client.go +++ b/abmclient/client.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "path/filepath" "time" "github.com/sirupsen/logrus" @@ -241,7 +242,7 @@ func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, e // GetPurchaseSourcesFromCache reads devices.json from cacheDir and returns // the unique purchase sources without making any ABM API calls. func GetPurchaseSourcesFromCache(cacheDir string) ([]PurchaseSource, error) { - data, err := os.ReadFile(cacheDir + "/devices.json") + data, err := os.ReadFile(filepath.Join(cacheDir, "devices.json")) if err != nil { return nil, fmt.Errorf("reading devices cache: %w", err) } diff --git a/snipe/client.go b/snipe/client.go index 69795da..c0581da 100644 --- a/snipe/client.go +++ b/snipe/client.go @@ -271,6 +271,9 @@ type FieldDef struct { // SetupFields creates or updates custom fields in Snipe-IT and associates them // with the given fieldset. Returns a map of field name -> db_column_name. func (c *Client) SetupFields(fieldsetID int, fields []FieldDef) (map[string]string, error) { + if c.DryRun { + return nil, ErrDryRun + } existing, _, err := c.Fields.List(nil) if err != nil { return nil, fmt.Errorf("listing existing fields: %w", err) From 448d75c6256695a4033006ed70bea37b9ca46333 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 6 Mar 2026 22:40:10 -0500 Subject: [PATCH 3/4] refactor: extract collectPurchaseSources helper in abmclient Deduplicate the purchase source iteration logic into a shared helper that accepts []abm.OrgDevice. GetPurchaseSourcesFromCache unwraps []Device to []abm.OrgDevice before calling it. Co-Authored-By: Claude Sonnet 4.6 --- abmclient/client.go | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/abmclient/client.go b/abmclient/client.go index 87ea8be..5cef25d 100644 --- a/abmclient/client.go +++ b/abmclient/client.go @@ -213,13 +213,8 @@ type PurchaseSource struct { ID string // purchaseSourceId (may be empty) } -// GetAllPurchaseSources fetches all devices and returns the unique purchase sources. -func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, error) { - devices, _, err := c.abm.FetchAllOrgDevices(ctx) - if err != nil { - return nil, fmt.Errorf("fetching devices: %w", err) - } - +// collectPurchaseSources extracts unique purchase sources from a slice of ABM devices. +func collectPurchaseSources(devices []abm.OrgDevice) []PurchaseSource { seen := make(map[PurchaseSource]bool) var sources []PurchaseSource for _, d := range devices { @@ -236,7 +231,16 @@ func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, e sources = append(sources, ps) } } - return sources, nil + return sources +} + +// GetAllPurchaseSources fetches all devices and returns the unique purchase sources. +func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, error) { + devices, _, err := c.abm.FetchAllOrgDevices(ctx) + if err != nil { + return nil, fmt.Errorf("fetching devices: %w", err) + } + return collectPurchaseSources(devices), nil } // GetPurchaseSourcesFromCache reads devices.json from cacheDir and returns @@ -250,23 +254,11 @@ func GetPurchaseSourcesFromCache(cacheDir string) ([]PurchaseSource, error) { if err := json.Unmarshal(data, &devices); err != nil { return nil, fmt.Errorf("parsing devices cache: %w", err) } - seen := make(map[PurchaseSource]bool) - var sources []PurchaseSource - for _, d := range devices { - if d.Attributes == nil { - continue - } - srcType := string(d.Attributes.PurchaseSourceType) - if srcType == "" { - continue - } - ps := PurchaseSource{Type: srcType, ID: d.Attributes.PurchaseSourceID} - if !seen[ps] { - seen[ps] = true - sources = append(sources, ps) - } + orgDevices := make([]abm.OrgDevice, len(devices)) + for i, d := range devices { + orgDevices[i] = d.OrgDevice } - return sources, nil + return collectPurchaseSources(orgDevices), nil } // CoverageResult holds both the "winning" AppleCare record and the full list From 2217c1396f1b190973d31479fcd5aae491f53474 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 6 Mar 2026 23:59:35 -0500 Subject: [PATCH 4/4] refactor: move GetPurchaseSourcesFromCache to abmclient/cache.go Separate filesystem/JSON cache concerns from upstream-client behavior by relocating GetPurchaseSourcesFromCache into a dedicated cache.go file. Co-Authored-By: Claude Sonnet 4.6 --- abmclient/cache.go | 28 ++++++++++++++++++++++++++++ abmclient/client.go | 21 --------------------- 2 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 abmclient/cache.go diff --git a/abmclient/cache.go b/abmclient/cache.go new file mode 100644 index 0000000..65b1ad9 --- /dev/null +++ b/abmclient/cache.go @@ -0,0 +1,28 @@ +package abmclient + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/CampusTech/abm" +) + +// GetPurchaseSourcesFromCache reads devices.json from cacheDir and returns +// the unique purchase sources without making any ABM API calls. +func GetPurchaseSourcesFromCache(cacheDir string) ([]PurchaseSource, error) { + data, err := os.ReadFile(filepath.Join(cacheDir, "devices.json")) + if err != nil { + return nil, fmt.Errorf("reading devices cache: %w", err) + } + var devices []Device + if err := json.Unmarshal(data, &devices); err != nil { + return nil, fmt.Errorf("parsing devices cache: %w", err) + } + orgDevices := make([]abm.OrgDevice, len(devices)) + for i, d := range devices { + orgDevices[i] = d.OrgDevice + } + return collectPurchaseSources(orgDevices), nil +} diff --git a/abmclient/client.go b/abmclient/client.go index 5cef25d..d8f8b31 100644 --- a/abmclient/client.go +++ b/abmclient/client.go @@ -5,11 +5,8 @@ package abmclient import ( "context" - "encoding/json" "fmt" "io" - "os" - "path/filepath" "time" "github.com/sirupsen/logrus" @@ -243,24 +240,6 @@ func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, e return collectPurchaseSources(devices), nil } -// GetPurchaseSourcesFromCache reads devices.json from cacheDir and returns -// the unique purchase sources without making any ABM API calls. -func GetPurchaseSourcesFromCache(cacheDir string) ([]PurchaseSource, error) { - data, err := os.ReadFile(filepath.Join(cacheDir, "devices.json")) - if err != nil { - return nil, fmt.Errorf("reading devices cache: %w", err) - } - var devices []Device - if err := json.Unmarshal(data, &devices); err != nil { - return nil, fmt.Errorf("parsing devices cache: %w", err) - } - orgDevices := make([]abm.OrgDevice, len(devices)) - for i, d := range devices { - orgDevices[i] = d.OrgDevice - } - return collectPurchaseSources(orgDevices), nil -} - // CoverageResult holds both the "winning" AppleCare record and the full list // of all coverage records for a device. type CoverageResult struct {