sidebranch 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/LICENSE +28 -0
- package/README.md +368 -0
- package/SECURITY.md +161 -0
- package/bin/sidebranch.js +12 -0
- package/package.json +49 -0
- package/src/assets/boot-tag.js +20 -0
- package/src/assets/geist-pixel.LICENSE.txt +133 -0
- package/src/assets/geist-pixel.woff2 +0 -0
- package/src/assets/shell.html +930 -0
- package/src/assets/widget-core.js +898 -0
- package/src/cli.js +342 -0
- package/src/config.js +120 -0
- package/src/daemon.js +323 -0
- package/src/daemonfile.js +156 -0
- package/src/gitops.js +264 -0
- package/src/install.js +115 -0
- package/src/manager.js +260 -0
- package/src/processes.js +250 -0
- package/src/proxy.js +136 -0
- package/src/security.js +123 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to sidebranch. This project follows
|
|
4
|
+
[semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Browser extension** (`extension/`) — a second delivery channel for the
|
|
11
|
+
widget: install once and every project you run `sidebranch start` in gets
|
|
12
|
+
the pill, with no `<script>` tag in your app. MV3, loadable unpacked, not
|
|
13
|
+
part of the npm package. Permissions are `storage` (a port override) plus
|
|
14
|
+
`http://localhost/*` and `http://127.0.0.1/*`. It has no service worker, so
|
|
15
|
+
all daemon traffic goes through the content script and every invariant in
|
|
16
|
+
`SECURITY.md` still holds. Unlike the tag, it can't be blocked by a page's
|
|
17
|
+
CSP — and can't reach a page served from `http://[::1]`.
|
|
18
|
+
- **Extension toolbar popup** — undoes "Hide for this session", which is per
|
|
19
|
+
tab and previously needed a new tab to clear.
|
|
20
|
+
- **View ports** — each pane gets a local pass-through proxy that deletes
|
|
21
|
+
`X-Frame-Options` and CSP `frame-ancestors`, so the compare view works with
|
|
22
|
+
apps that refuse framing. Removals are named in a `Sidebranch-Removed-Headers`
|
|
23
|
+
response header, bodies stream through byte-identical, and the proxy carries
|
|
24
|
+
the daemon's loopback/Host gate (upgrades included). Set `"frameProxy": false`
|
|
25
|
+
to disable. Direct pane ports are untouched.
|
|
26
|
+
- **Frame-refusal detection** — the daemon probes for framing headers once per
|
|
27
|
+
server start, and the shell names the header and the fix instead of rendering
|
|
28
|
+
a blank rectangle.
|
|
29
|
+
- **Info flyout** in the widget toolbar (ⓘ): start and stop commands with this
|
|
30
|
+
daemon's port substituted, click-to-copy, and which delivery channel you're
|
|
31
|
+
looking at.
|
|
32
|
+
- `sidebranch stop` — shuts down the daemon and its pane dev servers from any
|
|
33
|
+
terminal. Backed by a per-repo `daemon.json` record, which also lets `start`
|
|
34
|
+
refuse to launch over an existing daemon, lets `clean` refuse to delete
|
|
35
|
+
worktrees out from under a running dev server, and lets `doctor` report
|
|
36
|
+
daemon status. Stale records are detected and cleared; no command signals a
|
|
37
|
+
process it hasn't confirmed is a sidebranch daemon.
|
|
38
|
+
- `GET /handshake` — unauthenticated credential bootstrap returning
|
|
39
|
+
`{token, port, widget, version, apiVersion}` for the extension, behind the
|
|
40
|
+
same loopback/Host/Origin gate as every other route. Discloses nothing that
|
|
41
|
+
`GET /widget.js` did not.
|
|
42
|
+
- `fontSource` option in `widget-core.js`, so a caller can pass the font as
|
|
43
|
+
binary instead of a URL. The extension uses it because a `FontFace` built
|
|
44
|
+
from a URL is subject to the page's `font-src` CSP.
|
|
45
|
+
- `src/assets/geist-pixel.LICENSE.txt` — the bundled font is SIL OFL 1.1, not
|
|
46
|
+
MIT, and previously shipped without its license text.
|
|
47
|
+
- A landing page under `site/`, deployed to GitHub Pages. Not part of the npm
|
|
48
|
+
package and adds no dependencies.
|
|
49
|
+
- CI: the suite runs on Node 20/22/24 across Linux and macOS and asserts the
|
|
50
|
+
dependency count is still zero.
|
|
51
|
+
- `prepublishOnly` runs the suite, so a red build can't reach the registry.
|
|
52
|
+
- Releases publish from a tagged workflow with npm provenance.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- The bundled UI font is now `geist-pixel.woff2` (24 KB) instead of
|
|
57
|
+
`geist-pixel.ttf` (3.7 MB). All 481 glyphs are retained and nothing renders
|
|
58
|
+
differently; the package drops from 3.8 MB unpacked to ~150 KB.
|
|
59
|
+
- `src/assets/widget.js` split into `widget-core.js` (the UI) and `boot-tag.js`
|
|
60
|
+
(the tag channel's credential delivery). No user-visible change —
|
|
61
|
+
`GET /widget.js` serves the same assembled script. This is what lets the
|
|
62
|
+
extension ship the identical widget without shipping a secret.
|
|
63
|
+
- `SECURITY.md` names a concrete reporting route.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- A worktree deleted from disk but still registered in git no longer wedges its
|
|
68
|
+
pane with a misleading `spawn git ENOENT`. `ensurePane` prunes the stale
|
|
69
|
+
registration and recreates the worktree; `doctor` reports the state. Also
|
|
70
|
+
fixes worktree path matching for deleted directories on macOS tmp paths
|
|
71
|
+
(the `/var` → `/private/var` symlink).
|
|
72
|
+
- Piping CLI output (`sidebranch stop | head -1`) no longer crashes with EPIPE
|
|
73
|
+
when the pipe closes early.
|
|
74
|
+
- The extension no longer injects the widget into the daemon's own `/shell`
|
|
75
|
+
page, which matched the content script like any other localhost dev server.
|
|
76
|
+
- CI runs on Node 20 again. `node --test "test/*.test.js"` relies on glob
|
|
77
|
+
expansion Node only gained in v21, so both Node 20 jobs had been failing
|
|
78
|
+
without running a test. The glob is now unquoted and expanded by the shell.
|
|
79
|
+
|
|
80
|
+
## [0.1.0]
|
|
81
|
+
|
|
82
|
+
Initial release: loopback-only PR review sidecar with worktree panes, an
|
|
83
|
+
in-page widget, and the side-by-side / blend / layer compare shell.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sidebranch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The bundled font `src/assets/geist-pixel.woff2` is NOT covered by the MIT
|
|
26
|
+
license above. It is a modified build of Geist Pixel, licensed under the SIL
|
|
27
|
+
Open Font License, Version 1.1. See `src/assets/geist-pixel.LICENSE.txt` for
|
|
28
|
+
the full text, the copyright notices, and a record of the modifications.
|
package/README.md
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# sidebranch
|
|
2
|
+
|
|
3
|
+
Review pull requests from inside your running app. sidebranch is a local
|
|
4
|
+
sidecar daemon plus a small in-page widget: pick a branch from a floating
|
|
5
|
+
control and it appears in a dedicated **review worktree** on its own dev
|
|
6
|
+
server. Your working tree, uncommitted changes and all, is never touched.
|
|
7
|
+
Open a PR branch next to `main` in a synced side-by-side view, or stack them
|
|
8
|
+
with a blend/layer diff to spot visual regressions.
|
|
9
|
+
|
|
10
|
+
- **Framework-agnostic.** Next on 3000, Vite on 5173, Django on 8080 — if it
|
|
11
|
+
starts from a command and answers HTTP on a port, it works.
|
|
12
|
+
- **Nothing leaves your machine.** The daemon binds loopback only, every
|
|
13
|
+
request is authenticated, and the widget is inert outside a localhost page.
|
|
14
|
+
Zero runtime dependencies — Node builtins only.
|
|
15
|
+
- **Unobtrusive.** One small pill in the corner, rendered only when the daemon
|
|
16
|
+
is running. `×` hides it for the session, Esc closes the panel, and
|
|
17
|
+
`"widget": false` turns it off entirely.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
your app (:5173, your working tree — never touched)
|
|
23
|
+
└── <script src="http://localhost:49400/widget.js"> ← the widget
|
|
24
|
+
sidebranch daemon (127.0.0.1:49400)
|
|
25
|
+
├── pane A → worktree ~/.sidebranch/…/panes/a → dev server :4410
|
|
26
|
+
├── pane B → worktree ~/.sidebranch/…/panes/b → dev server :4411
|
|
27
|
+
└── /shell → compare view (side-by-side / blend / layer)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Panes are persistent review environments built on `git worktree`. Switching a
|
|
31
|
+
pane to another branch is a checkout inside that pane. Dependencies are
|
|
32
|
+
re-installed only when a lockfile actually changed (by content hash), and the
|
|
33
|
+
dev server restarts only in that case; otherwise its file watcher picks up the
|
|
34
|
+
checkout like any other change. Panes are never edited by hand, so they are
|
|
35
|
+
always clean — nothing to stash, nothing to lose.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
cd your-repo
|
|
41
|
+
npx sidebranch init # writes .sidebranch.json — edit for your stack
|
|
42
|
+
npx sidebranch start # daemon on http://127.0.0.1:49400
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Add the widget to your app, **dev builds only**:
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<script src="http://localhost:49400/widget.js" defer></script>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Gate it however your stack gates dev-only code, e.g.:
|
|
52
|
+
|
|
53
|
+
```jsx
|
|
54
|
+
// Next.js (app/layout.tsx)
|
|
55
|
+
{process.env.NODE_ENV === "development" && (
|
|
56
|
+
<script src="http://localhost:49400/widget.js" defer />
|
|
57
|
+
)}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```html
|
|
61
|
+
<!-- Vite (index.html) -->
|
|
62
|
+
<script>
|
|
63
|
+
if (import.meta.env.DEV) {
|
|
64
|
+
const s = document.createElement("script");
|
|
65
|
+
s.src = "http://localhost:49400/widget.js"; s.defer = true;
|
|
66
|
+
document.head.appendChild(s);
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If the tag does ship to production, it's harmless: the widget refuses to run
|
|
72
|
+
on non-localhost pages, and a visitor's own daemon — if they run one — rejects
|
|
73
|
+
requests from non-loopback origins. See [SECURITY.md](SECURITY.md).
|
|
74
|
+
|
|
75
|
+
Then: click the pill → pick a branch → it builds and serves in pane A.
|
|
76
|
+
**Open A** views it; **Compare A/B** opens the split view.
|
|
77
|
+
|
|
78
|
+
### Or skip the tag: the browser extension
|
|
79
|
+
|
|
80
|
+
The widget also ships as a Chrome/Edge extension, which puts the same pill on
|
|
81
|
+
every project you run `sidebranch start` in without touching any app's HTML.
|
|
82
|
+
It lives in [`extension/`](extension/) and loads unpacked:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
chrome://extensions → Developer mode → Load unpacked → select extension/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
It requests two origins — `http://localhost/*` and `http://127.0.0.1/*` — and
|
|
89
|
+
one permission, `storage`, for a port override on its options page. It talks
|
|
90
|
+
to nothing but your own daemon, and renders nothing if none is running.
|
|
91
|
+
|
|
92
|
+
Two differences from the tag:
|
|
93
|
+
|
|
94
|
+
- **A strict `Content-Security-Policy` can block the script tag.** It can't
|
|
95
|
+
block the extension, whose content script isn't subject to the page's CSP.
|
|
96
|
+
(The widget's font is bundled and loaded as binary so a strict `font-src`
|
|
97
|
+
can't downgrade it either.)
|
|
98
|
+
- **A page served from `http://[::1]:5173` gets no widget from the
|
|
99
|
+
extension** — Chrome match patterns can't express an IPv6 literal. Use the
|
|
100
|
+
tag, or `http://localhost`, which reaches the same server.
|
|
101
|
+
|
|
102
|
+
Everything else is identical, including the compare view. See
|
|
103
|
+
[`extension/README.md`](extension/README.md) for details.
|
|
104
|
+
|
|
105
|
+
Used **Hide for this session** and want the widget back? The flag is per tab,
|
|
106
|
+
so a reload won't clear it — click the sidebranch toolbar icon and choose
|
|
107
|
+
**Show the widget here**. (With the script tag, open a new tab.)
|
|
108
|
+
|
|
109
|
+
## Configuration (`.sidebranch.json`)
|
|
110
|
+
|
|
111
|
+
```jsonc
|
|
112
|
+
{
|
|
113
|
+
"dev": "npm run dev", // start command, run inside each pane
|
|
114
|
+
// {port} is substituted; PORT env is always set
|
|
115
|
+
"install": "pnpm install", // run only when lockfiles change; "" disables
|
|
116
|
+
"ready": { "path": "/", "statuses": null }, // readiness probe; null = any HTTP answer
|
|
117
|
+
"panes": 2, // review environments kept warm (1–4)
|
|
118
|
+
"basePort": 4410, // first port tried for pane servers
|
|
119
|
+
"copy": [".env", ".env.local"], // untracked files copied into new worktrees
|
|
120
|
+
"env": {}, // extra env vars for the pane dev command
|
|
121
|
+
// overrides inherited values; PORT etc. are reserved
|
|
122
|
+
"lockfiles": ["package-lock.json"], // override the manifest list if needed
|
|
123
|
+
"widget": true, // false → /widget.js serves a no-op
|
|
124
|
+
"frameProxy": true // false → compare view frames panes directly
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Recipes:
|
|
129
|
+
|
|
130
|
+
| Stack | `dev` | `install` |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| Next.js | `npm run dev` | `npm install` |
|
|
133
|
+
| Vite | `npx vite --port {port}` | `pnpm install` |
|
|
134
|
+
| Django | `python3 manage.py runserver 127.0.0.1:{port}` | `pip install -r requirements.txt` |
|
|
135
|
+
| Rails | `bin/rails server -p {port}` | `bundle install` |
|
|
136
|
+
| Static | `python3 -m http.server {port} --bind 127.0.0.1` | *(empty)* |
|
|
137
|
+
|
|
138
|
+
The daemon never parses server output. A pane is "ready" when its port answers
|
|
139
|
+
the probe, which also catches servers that silently pick a different port than
|
|
140
|
+
asked.
|
|
141
|
+
|
|
142
|
+
## Complex apps: multiple processes, databases, and credentials
|
|
143
|
+
|
|
144
|
+
sidebranch runs one process per pane and injects one port. Real apps often
|
|
145
|
+
need more — a database or emulator, an auth proxy, a background worker,
|
|
146
|
+
credentials for cloud services. That takes one architectural idea plus two
|
|
147
|
+
config levers.
|
|
148
|
+
|
|
149
|
+
### The one rule: per-pane process, shared singletons
|
|
150
|
+
|
|
151
|
+
Your `dev` command is the only thing sidebranch runs per pane, and it can only
|
|
152
|
+
vary one port. So anything that is a **singleton** — a database, an emulator
|
|
153
|
+
on a fixed port, an auth proxy — must run once, outside sidebranch, shared by
|
|
154
|
+
every pane and your own dev session. Keep the per-pane command down to the
|
|
155
|
+
single server that renders the branch.
|
|
156
|
+
|
|
157
|
+
If your normal dev command is a bundle like `concurrently "db" "web" "proxy"`,
|
|
158
|
+
don't point sidebranch at it — two panes would each try to start the db and
|
|
159
|
+
proxy on the same fixed ports and collide. Instead:
|
|
160
|
+
|
|
161
|
+
- point `dev` at just the web server (`"dev": "npm run dev:web"`),
|
|
162
|
+
- run the db/emulator/proxy once yourself, and
|
|
163
|
+
- tell each pane how to reach them with `env`.
|
|
164
|
+
|
|
165
|
+
### Two levers for everything a pane needs
|
|
166
|
+
|
|
167
|
+
| Need | Lever | How |
|
|
168
|
+
| --- | --- | --- |
|
|
169
|
+
| An untracked **file** the app reads (`.env`, a service-account key, a cert) | `copy` | Copied from your main tree into each pane once, at creation. |
|
|
170
|
+
| An **environment variable** (point at a shared service, flip a mode, unset a stale path) | `env` | Injected into the pane's dev command; overrides inherited values. |
|
|
171
|
+
| A credential in a **machine-wide, out-of-tree location** (`~/.config/gcloud`, `~/.aws`, `~/.netrc`) | *(nothing)* | Panes run as you, so home-dir credentials resolve for free. |
|
|
172
|
+
|
|
173
|
+
The third row is the one people miss: only in-repo, relative-path credentials
|
|
174
|
+
break in a pane, because they resolve against the pane's own directory.
|
|
175
|
+
Home-directory credentials need no `copy` and no `env`.
|
|
176
|
+
|
|
177
|
+
### `env`: precedence and the empty-string unset
|
|
178
|
+
|
|
179
|
+
`env` values layer on top of the inherited environment, so they override what
|
|
180
|
+
your shell exported and what a copied `.env` would set. The exception is the
|
|
181
|
+
vars sidebranch owns — `PORT`, `BROWSER`, `FORCE_COLOR`, `SIDEBRANCH` —
|
|
182
|
+
because overriding `PORT` would break port injection. Those are dropped from
|
|
183
|
+
`env` if you set them.
|
|
184
|
+
|
|
185
|
+
Setting a var to the **empty string** unsets an inherited value:
|
|
186
|
+
|
|
187
|
+
```jsonc
|
|
188
|
+
"env": { "GOOGLE_APPLICATION_CREDENTIALS": "" }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
That's the fix when a copied `.env` points a credential at an in-repo relative
|
|
192
|
+
path: blank it in the pane and let the SDK fall back to your machine's default
|
|
193
|
+
credentials.
|
|
194
|
+
|
|
195
|
+
### Worked example: web app + database emulator + cloud credentials
|
|
196
|
+
|
|
197
|
+
An app whose `npm run dev` starts an emulator, the web server, and a cloud
|
|
198
|
+
auth proxy, and whose `.env` sets a relative-path service-account key:
|
|
199
|
+
|
|
200
|
+
1. Keep the emulator running once (your normal dev session already starts it).
|
|
201
|
+
2. Point the pane at just the web server, tell it to use the shared emulator,
|
|
202
|
+
and blank the in-repo key so cloud SDKs fall back to machine ADC:
|
|
203
|
+
|
|
204
|
+
```jsonc
|
|
205
|
+
{
|
|
206
|
+
"dev": "npm run dev:web", // web server only — NOT the bundle
|
|
207
|
+
"install": "npm install",
|
|
208
|
+
"copy": [".env", ".env.local"], // carry app config/secrets, but not the key file
|
|
209
|
+
"env": {
|
|
210
|
+
"FIRESTORE_EMULATOR_HOST": "127.0.0.1:8080", // reach the shared emulator
|
|
211
|
+
"GOOGLE_APPLICATION_CREDENTIALS": "" // fall back to ~/.config/gcloud ADC
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
No credential file is copied into any pane, every pane points at the one
|
|
217
|
+
shared emulator, and other cloud calls authenticate via machine-wide ADC.
|
|
218
|
+
(Prerequisite: you've run your cloud CLI's "application default login" — the
|
|
219
|
+
same setup your app already needs locally.)
|
|
220
|
+
|
|
221
|
+
### Checklist for porting sidebranch into an app
|
|
222
|
+
|
|
223
|
+
1. **What does your dev command start?** If it's more than one server, split
|
|
224
|
+
off the singletons.
|
|
225
|
+
2. **Run the singletons once** (or confirm your normal dev session does).
|
|
226
|
+
3. **Point `dev` at the single per-pane server**; make sure it honors `PORT`
|
|
227
|
+
(or use `{port}`).
|
|
228
|
+
4. **`copy` the untracked files** the app needs — except secrets you can reach
|
|
229
|
+
another way.
|
|
230
|
+
5. **For each service the app talks to**, choose: shared instance (`env` points
|
|
231
|
+
at it), machine-wide credential (free), or copied file (`copy`).
|
|
232
|
+
6. **Blank any in-repo relative credential paths** with `"env": { "VAR": "" }`.
|
|
233
|
+
7. **`npx sidebranch doctor`**, then `start`, then open a pane.
|
|
234
|
+
|
|
235
|
+
### Prompt your coding agent to do it
|
|
236
|
+
|
|
237
|
+
Paste this into Claude Code / Cursor / your agent of choice, from your repo
|
|
238
|
+
root — it produces a `.sidebranch.json` tailored to your stack:
|
|
239
|
+
|
|
240
|
+
```text
|
|
241
|
+
Integrate sidebranch (a local PR-review sidecar) into this app. Steps:
|
|
242
|
+
1. Read package.json (or the equivalent) and identify the dev command(s).
|
|
243
|
+
List every process and fixed port the normal dev workflow starts —
|
|
244
|
+
web server, database/emulator, proxies, workers.
|
|
245
|
+
2. Identify which of those are singletons (fixed ports, shared state) vs the
|
|
246
|
+
single web server that actually renders the app.
|
|
247
|
+
3. Find every credential/secret the app loads locally and where it comes
|
|
248
|
+
from: in-repo relative-path files, machine-wide locations (~/.config,
|
|
249
|
+
~/.aws), or environment variables.
|
|
250
|
+
4. Write a .sidebranch.json where:
|
|
251
|
+
- "dev" runs ONLY the web server, honoring the PORT env var (or {port}),
|
|
252
|
+
- "copy" lists the untracked files the app needs at runtime,
|
|
253
|
+
- "env" points the pane at the shared singletons and blanks ("") any
|
|
254
|
+
in-repo relative credential paths so SDKs fall back to machine defaults.
|
|
255
|
+
5. Add the widget to the app's HTML/layout, gated to development only:
|
|
256
|
+
<script src="http://localhost:49400/widget.js" defer></script>
|
|
257
|
+
6. Tell me exactly which singleton processes I must run once myself before
|
|
258
|
+
using panes, and any one-time credential setup (e.g. cloud ADC login).
|
|
259
|
+
Do not modify my existing dev scripts or app code beyond adding the widget tag.
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Compare view
|
|
263
|
+
|
|
264
|
+
`http://localhost:49400/shell` (or the **Compare A/B** button):
|
|
265
|
+
|
|
266
|
+
- **Side by side** — both panes live, one width control driving both viewports
|
|
267
|
+
in lockstep (slider + 375/768/1280 presets).
|
|
268
|
+
- **Blend diff** — panes stacked with `mix-blend-mode: difference`; identical
|
|
269
|
+
pixels go black, any change glows.
|
|
270
|
+
- **Layer diff** — stacked with an opacity slider on the top pane.
|
|
271
|
+
- **⇄ Swap** exchanges the panes' branches.
|
|
272
|
+
|
|
273
|
+
Scroll and interaction sync between frames is out of scope: the stacked modes
|
|
274
|
+
answer "did anything move?" and side-by-side answers "how does it behave?",
|
|
275
|
+
without proxying or injecting script into your app.
|
|
276
|
+
|
|
277
|
+
### Apps that refuse to be framed
|
|
278
|
+
|
|
279
|
+
Panes and the compare view run on different ports, so they're different
|
|
280
|
+
origins — an app sending `X-Frame-Options: SAMEORIGIN` or a restrictive
|
|
281
|
+
`frame-ancestors` would render as a blank frame.
|
|
282
|
+
|
|
283
|
+
So the compare view doesn't frame panes directly. Each pane gets a **view
|
|
284
|
+
port**: a local pass-through proxy that deletes those two framing headers and
|
|
285
|
+
nothing else, and names what it removed in a `Sidebranch-Removed-Headers`
|
|
286
|
+
response header. Your app's code, its other headers, and the direct pane port
|
|
287
|
+
(used by **Open in new tab**) are untouched, and your production config is
|
|
288
|
+
never involved. Set `"frameProxy": false` to turn this off; a refusing app
|
|
289
|
+
then gets an explanation in the pane instead of an embed. Details and
|
|
290
|
+
safeguards: [SECURITY.md](SECURITY.md).
|
|
291
|
+
|
|
292
|
+
## Non-goals and guarantees
|
|
293
|
+
|
|
294
|
+
- sidebranch never runs a mutating git command against your primary working
|
|
295
|
+
tree. No stash, no reset, no checkout — reads only.
|
|
296
|
+
- A dirty *pane* (someone edited a review worktree by hand) fails safe: the
|
|
297
|
+
switch is refused until you opt into discarding, and only files inside that
|
|
298
|
+
pane are affected.
|
|
299
|
+
- Worktrees live under `~/.sidebranch/`, outside your repo, so file watchers
|
|
300
|
+
and tooling in your main tree never see them.
|
|
301
|
+
|
|
302
|
+
## Stopping and cleanup
|
|
303
|
+
|
|
304
|
+
`sidebranch stop` shuts down the daemon and its pane dev servers from any
|
|
305
|
+
terminal, so you don't have to find the tab it's running in:
|
|
306
|
+
|
|
307
|
+
```sh
|
|
308
|
+
sidebranch stop
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Pane worktrees survive a stop, so the next `start` reuses them instead of
|
|
312
|
+
re-installing everything. That means a pane still holds the branch it last
|
|
313
|
+
checked out, and git refuses to check out a branch already checked out
|
|
314
|
+
elsewhere:
|
|
315
|
+
|
|
316
|
+
```
|
|
317
|
+
fatal: 'main' is already used by worktree at '/Users/you/.sidebranch/projects/.../panes/a'
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`sidebranch clean` removes those worktrees:
|
|
321
|
+
|
|
322
|
+
```sh
|
|
323
|
+
sidebranch clean # lists panes, asks to confirm, then removes them
|
|
324
|
+
sidebranch clean --pane a # target a single pane
|
|
325
|
+
sidebranch clean --yes # skip the confirmation prompt (for scripts/agents)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`clean` refuses to run while a daemon is serving this repo, since removing a
|
|
329
|
+
worktree out from under a running dev server would leave it serving a
|
|
330
|
+
directory that no longer exists. Run `stop` first; `clean` will say so if you
|
|
331
|
+
haven't.
|
|
332
|
+
|
|
333
|
+
Both commands work from a record the daemon writes to
|
|
334
|
+
`~/.sidebranch/projects/<repo>/daemon.json`. If a daemon is killed outright
|
|
335
|
+
(`kill -9`, a closed terminal), that record is left behind, and the next
|
|
336
|
+
`stop`, `start`, or `doctor` clears it. Neither command signals a process it
|
|
337
|
+
hasn't confirmed is a sidebranch daemon, so a stale record whose pid the OS
|
|
338
|
+
has recycled is harmless.
|
|
339
|
+
|
|
340
|
+
## Commands
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
sidebranch init write starter config
|
|
344
|
+
sidebranch start run the daemon (--port N, default 49400)
|
|
345
|
+
sidebranch stop stop the daemon serving this repo
|
|
346
|
+
sidebranch clean remove stale pane worktrees for this repo (--pane, --yes)
|
|
347
|
+
sidebranch doctor environment checks (including daemon status)
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Development
|
|
351
|
+
|
|
352
|
+
```sh
|
|
353
|
+
node --test test/*.test.js
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The suite covers the security gauntlet (token, Host/Origin gating, hostile ref
|
|
357
|
+
names), the full worktree lifecycle against real fixture repos, and an
|
|
358
|
+
end-to-end run that boots two panes on two branches and asserts both serve.
|
|
359
|
+
|
|
360
|
+
## License
|
|
361
|
+
|
|
362
|
+
MIT — see [LICENSE](LICENSE).
|
|
363
|
+
|
|
364
|
+
One exception: the bundled UI font (`src/assets/geist-pixel.woff2`) is a
|
|
365
|
+
modified build of [Geist Pixel](https://github.com/vercel/geist-font) under
|
|
366
|
+
the SIL Open Font License 1.1, not MIT. Its license, copyright notices, and
|
|
367
|
+
the modifications made are recorded in
|
|
368
|
+
[`src/assets/geist-pixel.LICENSE.txt`](src/assets/geist-pixel.LICENSE.txt).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Security model
|
|
2
|
+
|
|
3
|
+
sidebranch runs commands (`git`, your dev command, your install command) on
|
|
4
|
+
behalf of a browser page. As designed, **nothing ever leaves the local machine and nothing
|
|
5
|
+
non-local can ever reach in**.
|
|
6
|
+
|
|
7
|
+
## Invariants (not configurable)
|
|
8
|
+
|
|
9
|
+
1. **Loopback bind.** The daemon listens on `127.0.0.1` only. There is no
|
|
10
|
+
flag to bind elsewhere.
|
|
11
|
+
2. **Peer verification.** Every request additionally checks the socket's
|
|
12
|
+
remote address is loopback (defense in depth against local forwarders).
|
|
13
|
+
3. **Host header allowlist.** Requests whose `Host` is not
|
|
14
|
+
`localhost`/`127.x`/`[::1]` are rejected with 403. This defeats **DNS
|
|
15
|
+
rebinding**, where an attacker's domain resolves to 127.0.0.1 so a remote
|
|
16
|
+
page's requests arrive on a loopback socket — but with the attacker's
|
|
17
|
+
hostname in `Host`.
|
|
18
|
+
4. **Origin allowlist.** Browser requests carrying an `Origin` are rejected
|
|
19
|
+
unless that origin is itself a loopback http(s) origin. `Origin: null`
|
|
20
|
+
(sandboxed iframes, `file://`) is rejected. Non-loopback origins never
|
|
21
|
+
receive CORS headers, so even the responses they can't be blocked from
|
|
22
|
+
*requesting* are unreadable to them.
|
|
23
|
+
5. **Bearer token on every API call.** A 256-bit token is generated at
|
|
24
|
+
daemon start (rotates each run) and required — with constant-time
|
|
25
|
+
comparison — on all `/api/*` routes, including reads and the event
|
|
26
|
+
stream.
|
|
27
|
+
6. **No shell, ever.** All child processes use `execFile`/`spawn` with
|
|
28
|
+
argument arrays. Branch names are validated twice (a conservative
|
|
29
|
+
allowlist regex, then `git check-ref-format --branch`) before reaching
|
|
30
|
+
git; names shaped like options (`-D`), paths (`..`), or containing any
|
|
31
|
+
shell-significant byte are rejected at the API boundary with 400.
|
|
32
|
+
7. **No filesystem routing.** The daemon serves three embedded, fixed-path
|
|
33
|
+
assets (`widget.js`, `/shell`, and a bundled font used by both), a
|
|
34
|
+
credential bootstrap (`/handshake`), a liveness probe (`/healthz`), and
|
|
35
|
+
JSON APIs. Every servable path is a hardcoded route, not derived from
|
|
36
|
+
the request URL, so there is no traversal surface no matter how many
|
|
37
|
+
fixed routes that list grows to.
|
|
38
|
+
8. **The user's working tree is read-only territory.** The daemon never
|
|
39
|
+
runs a mutating git command outside its own worktrees under
|
|
40
|
+
`~/.sidebranch/`.
|
|
41
|
+
|
|
42
|
+
## Token delivery
|
|
43
|
+
|
|
44
|
+
The token reaches a browser two ways, and both are unauthenticated by
|
|
45
|
+
necessity — they are what *bootstrap* auth:
|
|
46
|
+
|
|
47
|
+
1. **Embedded** into `widget.js` and `/shell` at response time, for the
|
|
48
|
+
`<script src>` integration.
|
|
49
|
+
2. **Fetched** from `GET /handshake`, for the browser extension. An
|
|
50
|
+
extension cannot use route 1 at all: Manifest V3 forbids executing
|
|
51
|
+
remotely-fetched code, so the extension ships the widget in its own
|
|
52
|
+
package and has nowhere for a substituted token to arrive.
|
|
53
|
+
|
|
54
|
+
Route 2 discloses nothing route 1 does not. Any caller that clears the gate
|
|
55
|
+
below can already read the token out of the `widget.js` response body — the
|
|
56
|
+
two routes are the same disclosure to the same audience, differing only in
|
|
57
|
+
shape. Both are safe for the same reasons:
|
|
58
|
+
|
|
59
|
+
(The bundled font both of them load via `@font-face` is unauthenticated for
|
|
60
|
+
a different, simpler reason: a browser's font fetch cannot carry a custom
|
|
61
|
+
`Authorization` header at all, so gating it by token was never an option.
|
|
62
|
+
It carries no secret and no per-run state, so there's nothing at stake in
|
|
63
|
+
it being fetchable by anything that already clears the loopback/Host/Origin
|
|
64
|
+
gate below.)
|
|
65
|
+
|
|
66
|
+
- They are only reachable from a loopback socket with a loopback Host
|
|
67
|
+
(invariants 1–3), so only local software can request them at all.
|
|
68
|
+
- A **remote** page cannot read them: `fetch()` from a non-loopback origin
|
|
69
|
+
is rejected (invariant 4), and including `<script src="http://localhost:49400/widget.js">`
|
|
70
|
+
executes the code but cannot read its source — the token lives in a
|
|
71
|
+
closure, is never attached to `window`, the DOM, storage, cookies, or
|
|
72
|
+
URLs, and the widget exits before touching the token when the embedding
|
|
73
|
+
page is not loopback.
|
|
74
|
+
- Local software on your machine could read them — but local software can
|
|
75
|
+
already run `git` as you directly. sidebranch does not attempt to defend
|
|
76
|
+
you from your own machine; no local tool can.
|
|
77
|
+
|
|
78
|
+
## "What if the snippet ships to production?"
|
|
79
|
+
|
|
80
|
+
Designed to be a non-event, twice over:
|
|
81
|
+
|
|
82
|
+
1. The widget's first statement checks `location.hostname`; on any
|
|
83
|
+
non-loopback page it returns before creating DOM, globals, or network
|
|
84
|
+
traffic. Visitors see nothing and their browser sends nothing.
|
|
85
|
+
2. Even a hand-modified copy that skipped that check would be talking to
|
|
86
|
+
the *visitor's* `localhost:49400`. If they don't run sidebranch, the
|
|
87
|
+
request fails. If they do, their daemon rejects the request because the
|
|
88
|
+
page's Origin (your production domain) is not loopback — before any
|
|
89
|
+
token check is even consulted.
|
|
90
|
+
|
|
91
|
+
There is no third party in the path in any scenario: the script src is
|
|
92
|
+
loopback, the API is loopback, and there is no telemetry, no analytics, no
|
|
93
|
+
update check, and no outbound network code anywhere in the package.
|
|
94
|
+
|
|
95
|
+
## Supply chain
|
|
96
|
+
|
|
97
|
+
Zero runtime dependencies. The daemon, widget, and shell are Node builtins
|
|
98
|
+
and vanilla browser APIs only. What you audit in this repo is everything
|
|
99
|
+
that runs.
|
|
100
|
+
|
|
101
|
+
## The shell page
|
|
102
|
+
|
|
103
|
+
`/shell` is served with a strict CSP (`default-src 'none'` plus loopback
|
|
104
|
+
allowances for frames and fetch), `X-Frame-Options: DENY`, `nosniff`, and
|
|
105
|
+
`no-referrer`. It embeds only loopback iframes and talks only to the daemon.
|
|
106
|
+
|
|
107
|
+
## View ports (the compare view's proxy)
|
|
108
|
+
|
|
109
|
+
When `frameProxy` is on (the default), each pane gets a second loopback port —
|
|
110
|
+
a pass-through proxy the compare view frames instead of the pane itself. It
|
|
111
|
+
deletes exactly two things from responses: `X-Frame-Options` and the
|
|
112
|
+
`frame-ancestors` CSP directive, and declares what it removed in a
|
|
113
|
+
`Sidebranch-Removed-Headers` header. Bodies are streamed, never parsed or
|
|
114
|
+
rewritten; every other header passes through untouched.
|
|
115
|
+
|
|
116
|
+
Safeguards, mirroring the daemon's own gate:
|
|
117
|
+
|
|
118
|
+
- Binds `127.0.0.1` only; non-loopback peers and non-loopback `Host` headers
|
|
119
|
+
are rejected (on websocket upgrades too).
|
|
120
|
+
- `Sec-Fetch-Site: cross-site` is rejected, so a remote page cannot frame a
|
|
121
|
+
pane through it — the protection the stripped header was providing.
|
|
122
|
+
- The target is fixed at construction to its own pane's port. Nothing in the
|
|
123
|
+
request selects where traffic goes, so it cannot proxy to anything else.
|
|
124
|
+
- Dev only, local only, and off with `"frameProxy": false` — the compare view
|
|
125
|
+
then falls back to explaining a refused embed instead of hiding it.
|
|
126
|
+
|
|
127
|
+
## Residual risks
|
|
128
|
+
|
|
129
|
+
- **Your dev/install commands are trusted**, exactly like `npm run dev` is:
|
|
130
|
+
checking out and building a branch executes that branch's build tooling.
|
|
131
|
+
Reviewing a malicious PR locally is risky with or without sidebranch;
|
|
132
|
+
panes give you process isolation per branch but not a sandbox. If a PR is
|
|
133
|
+
untrusted, read the diff before you build it — with any tool.
|
|
134
|
+
- **`copy` files** (e.g. `.env`) are duplicated into pane worktrees on your
|
|
135
|
+
own disk under `~/.sidebranch/`. Destroying a pane removes its worktree.
|
|
136
|
+
- **HTTPS dev servers**: an https page cannot load the http widget (mixed
|
|
137
|
+
content). Run the daemon behind a locally-trusted cert if you need this;
|
|
138
|
+
do not weaken the invariants to work around it.
|
|
139
|
+
- **The browser extension**, where used, runs on every page served from
|
|
140
|
+
`localhost`/`127.0.0.1` — that is the scope it requests and the only
|
|
141
|
+
scope it requests. It holds no permission for any other origin, so it
|
|
142
|
+
cannot see, and is never injected into, ordinary browsing. Within that
|
|
143
|
+
scope it does what the script tag does: talk to your daemon, on your
|
|
144
|
+
machine. All of its daemon traffic is issued from the content script, so
|
|
145
|
+
those requests carry the loopback page's own `Origin` and are admitted
|
|
146
|
+
by invariant 4 unchanged — no extension origin is allowlisted, and the
|
|
147
|
+
invariants above are not relaxed for it in any way.
|
|
148
|
+
|
|
149
|
+
## Reporting
|
|
150
|
+
|
|
151
|
+
Please report vulnerabilities privately, not as a public issue:
|
|
152
|
+
|
|
153
|
+
- **Preferred:** [open a private security advisory](https://github.com/cristobalwee/sidebranch/security/advisories/new)
|
|
154
|
+
on the repository.
|
|
155
|
+
If you cannot use GitHub, open an issue asking for a private channel —
|
|
156
|
+
without any details of the vulnerability itself — and one will be
|
|
157
|
+
arranged.
|
|
158
|
+
|
|
159
|
+
Expect an acknowledgement within a few days. This is a small project
|
|
160
|
+
maintained by one person; there is no bounty program, but credit is given
|
|
161
|
+
in the changelog for any report that leads to a fix.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../src/cli.js";
|
|
3
|
+
|
|
4
|
+
// `sidebranch stop | head -1` must not crash on EPIPE when the pipe closes.
|
|
5
|
+
process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
|
|
6
|
+
|
|
7
|
+
main(process.argv.slice(2))
|
|
8
|
+
.then((code) => { if (typeof code === "number" && code !== 0) process.exit(code); })
|
|
9
|
+
.catch((err) => {
|
|
10
|
+
process.stderr.write(`sidebranch: ${err.message}\n`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|