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 75df527..d8f8b31 100644 --- a/abmclient/client.go +++ b/abmclient/client.go @@ -210,13 +210,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 { @@ -233,7 +228,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 } // CoverageResult holds both the "winning" AppleCare record and the full list 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") + } +} 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)