From 7696dd32d63af9686e6bc46593ba4a83de74a28e Mon Sep 17 00:00:00 2001 From: gappc Date: Mon, 29 Jun 2026 02:00:07 +0200 Subject: [PATCH] feat(fork): disable Cat Gatekeeper by default and cut the auto-update cord Fork of pi-web for self-hosting without third-party auto-updates. Cat Gatekeeper: - Default to disabled (web cat-settings.js CAT_DEFAULTS.enabled + server-side internal/server/settings.go default); tests adjusted for default-off. Auto-update: - updater.NewDisabled(): no background poll, no manual/remote checks, no outbound calls to npm/GitHub. - app.go uses it, drops the poll goroutine, and passes RunInstall: nil so /api/update returns 503. - Removed the in-app installer (internal/app/update.go) and its test; the in-app "update" button can no longer pull/replace the binary. - Neutered the package.json postinstall hook (no auto-download on install). Fork housekeeping: - repository.url and the (now-inert) updater endpoints repointed to gappc. - mise.toml pins go 1.25.5 + node 20 (Node 23+ ships a global localStorage that breaks the jsdom component tests). Verified: make build, go vet, go test ./..., and the cat/updater vitest suites pass under Node 20. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016XKac7MvCrhs197fwdpdfC --- internal/app/app.go | 12 ++-- internal/app/update.go | 64 ++----------------- internal/app/update_test.go | 56 ---------------- internal/server/settings.go | 2 +- internal/updater/updater.go | 26 ++++++-- mise.toml | 9 +++ package.json | 4 +- .../session/CatGatekeeperSettings.test.js | 6 +- .../cat-gatekeeper/cat-gatekeeper.test.js | 4 +- .../session/cat-gatekeeper/cat-settings.js | 2 +- 10 files changed, 50 insertions(+), 135 deletions(-) delete mode 100644 internal/app/update_test.go create mode 100644 mise.toml diff --git a/internal/app/app.go b/internal/app/app.go index 3595d4b6..6fd0bba9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -65,7 +65,9 @@ func Main(version string) { } authMiddleware := auth.New(token) - versionChecker := updater.New(version) + // Fork: the updater is inert — it reports the current build version but never + // contacts npm/GitHub and never offers an update. See internal/updater. + versionChecker := updater.NewDisabled(version) var srv *server.Server manager := workers.NewManager(func(sessionID, sessionPath string) (workers.ChatWorker, error) { @@ -87,8 +89,10 @@ func Main(version string) { Models: func(ctx context.Context) (json.RawMessage, error) { return defaultModelsCache.get(ctx) }, - Updater: versionChecker, - RunInstall: runInstall, + Updater: versionChecker, + // RunInstall is intentionally nil in this fork: in-app self-update (which + // would pull and swap the binary via `pi install`) is disabled, so + // /api/update responds 503. RunRestart: runRestart, }) if srvErr != nil { @@ -178,8 +182,6 @@ func Main(version string) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - go versionChecker.Start(ctx) - go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/internal/app/update.go b/internal/app/update.go index 3d7d4559..e7221321 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -1,76 +1,20 @@ package app import ( - "context" "fmt" "os" "os/exec" - "path/filepath" "runtime" "time" ) -// installChannel matches the dist-tag pi-web is published under and the -// updater queries (see internal/updater). -const installPackage = "npm:@ygncode/pi-web@beta" - -// inPlaceUpdateEnv signals install.sh (the package postinstall) that pi-web is -// updating itself in place. install.sh then skips the service stop/restart: -// this script runs inside the process tree that restart would kill, aborting -// the in-flight npm install. pi-web restarts itself afterward via runRestart. -const inPlaceUpdateEnv = "PI_WEB_INPLACE_UPDATE" - -// installCmd builds the `pi install` invocation used by the in-app updater. -func installCmd(ctx context.Context) *exec.Cmd { - cmd := exec.CommandContext(ctx, "pi", "install", installPackage) - cmd.Env = append(os.Environ(), inPlaceUpdateEnv+"=1") - return cmd -} - -// cleanupStaleNPMTemps removes npm's hidden backup directories for pi-web. -// Interrupted installs can leave these behind, and later npm installs may fail -// before package scripts run with ENOTEMPTY while trying to rename the package -// directory into one of these stale paths. -func cleanupStaleNPMTemps() { - agentRoot := os.Getenv("PI_CODING_AGENT_DIR") - if agentRoot == "" { - home, err := os.UserHomeDir() - if err != nil || home == "" { - return - } - agentRoot = filepath.Join(home, ".pi", "agent") - } - - pattern := filepath.Join(agentRoot, "npm", "node_modules", "@ygncode", ".pi-web-*") - matches, err := filepath.Glob(pattern) - if err != nil { - return - } - for _, path := range matches { - _ = os.RemoveAll(path) - } -} - -// runInstall installs the latest pi-web package via the `pi` CLI. Output is -// captured so a failure surfaces a useful message in the UI. -func runInstall(ctx context.Context) error { - cleanupStaleNPMTemps() - cmd := installCmd(ctx) - out, err := cmd.CombinedOutput() - if err != nil { - msg := string(out) - if len(msg) > 500 { - msg = msg[len(msg)-500:] - } - return fmt.Errorf("%v: %s", err, msg) - } - return nil -} - -// runRestart restarts the pi-web service so the freshly installed binary takes +// runRestart restarts the pi-web service so a manually-installed binary takes // over. The restart command is detached into its own session so it survives // this process being torn down by the service manager. A fallback timer exits // the process if the service manager does not replace us promptly. +// +// Note: this fork has no in-app installer (see internal/app/app.go) — restart is +// kept as a standalone action and never pulls a new binary. func runRestart() error { var cmd *exec.Cmd switch runtime.GOOS { diff --git a/internal/app/update_test.go b/internal/app/update_test.go deleted file mode 100644 index c0d9d0f1..00000000 --- a/internal/app/update_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package app - -import ( - "context" - "os" - "path/filepath" - "slices" - "testing" -) - -// The in-app updater must hand install.sh the in-place flag so it skips the -// service stop/restart (which would kill the npm process running it). -func TestInstallCmdSignalsInPlaceUpdate(t *testing.T) { - cmd := installCmd(context.Background()) - - wantArgs := []string{"pi", "install", installPackage} - if !slices.Equal(cmd.Args, wantArgs) { - t.Fatalf("args = %v, want %v", cmd.Args, wantArgs) - } - - want := inPlaceUpdateEnv + "=1" - if !slices.Contains(cmd.Env, want) { - t.Errorf("env missing %q; got %v", want, cmd.Env) - } -} - -func TestCleanupStaleNPMTemps(t *testing.T) { - t.Setenv("PI_CODING_AGENT_DIR", "") - t.Setenv("HOME", t.TempDir()) - - home, err := os.UserHomeDir() - if err != nil { - t.Fatal(err) - } - scopeDir := filepath.Join(home, ".pi", "agent", "npm", "node_modules", "@ygncode") - staleDir := filepath.Join(scopeDir, ".pi-web-F7YwHA7A") - keepDir := filepath.Join(scopeDir, "pi-web") - if err := os.MkdirAll(filepath.Join(staleDir, "nested"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(keepDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(staleDir, "nested", "file"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - - cleanupStaleNPMTemps() - - if _, err := os.Stat(staleDir); !os.IsNotExist(err) { - t.Fatalf("stale temp dir still exists or stat failed: %v", err) - } - if _, err := os.Stat(keepDir); err != nil { - t.Fatalf("real package dir should remain: %v", err) - } -} diff --git a/internal/server/settings.go b/internal/server/settings.go index 1dd2d689..440cd1d9 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -55,7 +55,7 @@ var settingDefaults = map[string]string{ "pi-share:v1:done-sound": "cat.mp3", "pi-sessions:view-layout": "timeline", "pi-web:v1:show-btw-in-index": "false", - "pi-web:v1:cat:enabled": "true", + "pi-web:v1:cat:enabled": "false", "pi-web:v1:cat:focus-min": "25", "pi-web:v1:cat:break-min": "5", "pi-web:v1:cat:bedtime": "23:00", diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 1d610adc..288fa244 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -20,9 +20,11 @@ import ( ) const ( - defaultNPMURL = "https://registry.npmjs.org/@ygncode/pi-web" - defaultGitHubAPI = "https://api.github.com/repos/ygncode/pi-web" - // npmChannel is the dist-tag pi-web installs from (see pi install command). + // NOTE: this fork builds the Checker via NewDisabled(), so these endpoints + // are never contacted; they are kept pointed at the fork for consistency. + defaultNPMURL = "https://registry.npmjs.org/@gappc/pi-web" + defaultGitHubAPI = "https://api.github.com/repos/gappc/pi-web" + // npmChannel is the dist-tag the (now-disabled) updater would query. npmChannel = "beta" // PollInterval is how often the background goroutine refreshes the cache. PollInterval = 6 * time.Hour @@ -49,6 +51,7 @@ var devVersionRe = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}|-dirty$`) // check. It is safe for concurrent use. type Checker struct { current string + disabled bool npmURL string githubAPI string client *http.Client @@ -74,6 +77,17 @@ func New(version string) *Checker { } } +// NewDisabled builds a Checker that never contacts npm or GitHub. It still +// reports the current build version (so the UI can display it) but never polls +// in the background, never performs a manual check, and never reports an +// available update. Forks that build their own binary use this to cut the +// auto-update cord. +func NewDisabled(version string) *Checker { + c := New(version) + c.disabled = true + return c +} + // isDev reports whether the current build is a local/dev build that should // never be compared against published releases. This covers the literal "dev" // sentinel as well as `git describe` builds that are ahead of a tag or dirty — @@ -110,7 +124,7 @@ func (c *Checker) snapshotLocked() Info { // resulting snapshot. For dev builds it short-circuits and only stamps // checkedAt so the UI can show "checked just now". func (c *Checker) Check(ctx context.Context) (Info, error) { - if c.isDev() { + if c.isDev() || c.disabled { c.mu.Lock() c.checkedAt = time.Now() info := c.snapshotLocked() @@ -143,7 +157,7 @@ func (c *Checker) Check(ctx context.Context) (Info, error) { // Start runs an initial check shortly after launch, then refreshes every // PollInterval until ctx is cancelled. Intended to be run in its own goroutine. func (c *Checker) Start(ctx context.Context) { - if c.isDev() { + if c.isDev() || c.disabled { return } // Small delay so startup isn't blocked on the network. @@ -181,7 +195,7 @@ func (c *Checker) fetchLatestVersion(ctx context.Context) (string, error) { if v := doc.DistTags["latest"]; v != "" { return v, nil } - return "", fmt.Errorf("no published version found for @ygncode/pi-web") + return "", fmt.Errorf("no published version found for @gappc/pi-web") } // fetchChangelog tries the version-specific GitHub release first, then the diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..4d606ff2 --- /dev/null +++ b/mise.toml @@ -0,0 +1,9 @@ +# Toolchain pins for this pi-web fork (used by `mise` — https://mise.jdx.dev). +# +# node is pinned to 20 on purpose: the frontend test suite runs under jsdom, and +# Node 23+ ships its own experimental global `localStorage` that lacks `clear()` +# and collides with jsdom's, breaking component tests (e.g. CatGatekeeperSettings). +# go matches the version in go.mod. +[tools] +go = "1.25.5" +node = "20" diff --git a/package.json b/package.json index c92b502f..a899ddd0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ] }, "scripts": { - "postinstall": "bash install.sh", + "postinstall": "echo 'pi-web fork: auto-download/auto-update disabled. Build the binary yourself with: make build'", "preuninstall": "bash uninstall.sh", "test:extensions": "vitest run --config tests/extensions/vitest.config.ts" }, @@ -27,7 +27,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/ygncode/pi-web" + "url": "https://github.com/gappc/pi-web" }, "license": "MIT", "devDependencies": { diff --git a/web/src/components/session/CatGatekeeperSettings.test.js b/web/src/components/session/CatGatekeeperSettings.test.js index 72eff768..d112c40a 100644 --- a/web/src/components/session/CatGatekeeperSettings.test.js +++ b/web/src/components/session/CatGatekeeperSettings.test.js @@ -12,10 +12,10 @@ describe('CatGatekeeperSettings', () => { render(CatGatekeeperSettings, { props: { open: true } }); await tick(); const toggle = document.querySelector('.cat-settings-toggle'); - expect(toggle.checked).toBe(true); + expect(toggle.checked).toBe(false); - await fireEvent.change(toggle, { target: { checked: false } }); - expect(loadCatSettings({ storage: localStorage }).enabled).toBe(false); + await fireEvent.change(toggle, { target: { checked: true } }); + expect(loadCatSettings({ storage: localStorage }).enabled).toBe(true); }); it('persists a clamped number field', async () => { diff --git a/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js b/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js index 3952fd06..4d4999aa 100644 --- a/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js +++ b/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js @@ -46,7 +46,9 @@ function makeView() { // Build a harness with a controllable clock and active state. function harness({ hour = 10, minute = 0, settings } = {}) { const storage = makeStorage(); - if (settings) saveCatSettings(settings, { storage }); + // The gatekeeper now ships disabled by default; these tests exercise its + // active behavior, so enable it unless a test explicitly overrides `enabled`. + saveCatSettings({ enabled: true, ...settings }, { storage }); const base = new Date(2026, 4, 31, hour, minute, 0).getTime(); const clock = { ms: 0 }; diff --git a/web/src/session/cat-gatekeeper/cat-settings.js b/web/src/session/cat-gatekeeper/cat-settings.js index bf375dc3..1d35db7c 100644 --- a/web/src/session/cat-gatekeeper/cat-settings.js +++ b/web/src/session/cat-gatekeeper/cat-settings.js @@ -17,7 +17,7 @@ export const CAT_KEYS = { }; export const CAT_DEFAULTS = { - enabled: true, + enabled: false, focusMin: 25, breakMin: 5, bedtime: '23:00',