5db3e48262
* feat: add opt-in plugin auto-updater Automatically update installed plugins in the background, off by default and opt-in. Builds on the existing verified install pipeline — an auto-update is just an automatic reinstall of a newer catalog package_url. Two-tier toggle on Settings > Plugins: an instance-wide default (SQLite plugin_settings) plus a per-plugin override (tri-state auto_update on the plugin record, carried forward on upgrade). The AutoUpdatePoller (~90-min cadence + a boot sweep) upgrades only plugins that are currently active, opted in, and report update_available; disabled/errored plugins stay on their installed version. New endpoints: GET/PUT /api/plugins/settings and PUT /api/plugins/:id/auto-update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): recheck auto-update eligibility at install and fix test mock Address PR #1903 review feedback: - Re-check each plugin's current status + effective opt-in immediately before InstallFromURL (eligibleForAutoUpdate), closing the TOCTOU window between the candidate snapshot / catalog fetch and the install so a plugin disabled or opted out mid-sweep is never reinstalled and reactivated. - Correct the marketplace spec to describe the real failure behavior (shared with manual updates) instead of promising a version rollback the install pipeline does not perform. - Fix the frontend CI failure: the plugins page test mocked the whole plugins-api module without the new settings/auto-update functions, so useAutoUpdateSettings threw at mount. - Add an aria-label to the per-plugin auto-update Switch and Reset control. - Add a use-auto-update-settings hook test (load, failure, optimistic setDefault, rollback) and an eligibleForAutoUpdate unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
2.8 KiB
Go
92 lines
2.8 KiB
Go
package plugins
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
)
|
|
|
|
// settingsRowID is the fixed primary key of the single instance-wide plugin
|
|
// settings row, so upserts never duplicate it (the table is CHECK-constrained
|
|
// to this one id).
|
|
const settingsRowID = 1
|
|
|
|
// Settings holds instance-wide plugin preferences (currently just the
|
|
// auto-update default). It is a value type so callers can read it once and
|
|
// pass it around without touching the store again.
|
|
type Settings struct {
|
|
// AutoUpdateDefault is the instance-wide default applied to every installed
|
|
// plugin that has no per-plugin override (store.Record.AutoUpdate == nil).
|
|
// Defaults to false — auto-update is strictly opt-in.
|
|
AutoUpdateDefault bool `json:"auto_update_default"`
|
|
}
|
|
|
|
// settingsStore persists instance-wide plugin settings in the single-row
|
|
// plugin_settings table. It mirrors marketplace.SourceStore's self-initializing
|
|
// schema convention (the plugin subsystem's SQLite tables init in-package
|
|
// rather than through the central base_schema migrations).
|
|
type settingsStore struct {
|
|
db *sqlx.DB
|
|
ro *sqlx.DB
|
|
}
|
|
|
|
// newSettingsStore creates a settingsStore and initializes its schema.
|
|
func newSettingsStore(pool *db.Pool) (*settingsStore, error) {
|
|
s := &settingsStore{db: pool.Writer(), ro: pool.Reader()}
|
|
if err := s.initSchema(); err != nil {
|
|
return nil, fmt.Errorf("plugin settings schema init: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *settingsStore) initSchema() error {
|
|
_, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS plugin_settings (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
auto_update_default INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
// Get returns the instance-wide settings. When no row has been written yet it
|
|
// returns the zero-value defaults (auto-update off) rather than an error, so
|
|
// callers never have to special-case a fresh install.
|
|
func (s *settingsStore) Get() (Settings, error) {
|
|
var autoUpdate int
|
|
err := s.ro.QueryRow(s.ro.Rebind(`
|
|
SELECT auto_update_default FROM plugin_settings WHERE id = ?
|
|
`), settingsRowID).Scan(&autoUpdate)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Settings{}, nil
|
|
}
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return Settings{AutoUpdateDefault: autoUpdate != 0}, nil
|
|
}
|
|
|
|
// SetAutoUpdateDefault upserts the instance-wide auto-update default.
|
|
func (s *settingsStore) SetAutoUpdateDefault(enabled bool) error {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, err := s.db.Exec(s.db.Rebind(`
|
|
INSERT INTO plugin_settings (id, auto_update_default, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET auto_update_default = excluded.auto_update_default, updated_at = excluded.updated_at
|
|
`), settingsRowID, boolToInt(enabled), now)
|
|
return err
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|