Skip to content
Closed
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
12 changes: 7 additions & 5 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 4 additions & 60 deletions internal/app/update.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
56 changes: 0 additions & 56 deletions internal/app/update_test.go

This file was deleted.

2 changes: 1 addition & 1 deletion internal/server/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 20 additions & 6 deletions internal/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 —
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -27,7 +27,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/ygncode/pi-web"
"url": "https://github.com/gappc/pi-web"
},
"license": "MIT",
"devDependencies": {
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/session/CatGatekeeperSettings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 3 additions & 1 deletion web/src/session/cat-gatekeeper/cat-gatekeeper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion web/src/session/cat-gatekeeper/cat-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const CAT_KEYS = {
};

export const CAT_DEFAULTS = {
enabled: true,
enabled: false,
focusMin: 25,
breakMin: 5,
bedtime: '23:00',
Expand Down