reviewstage 1.0.0-rc.24
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/LICENSE +21 -0
- package/README.md +60 -0
- package/bin/reviewstage.js +49 -0
- package/flock.py +87 -0
- package/main.js +281 -0
- package/package.json +44 -0
- package/pages/phone.html +79 -0
- package/pages/preparing.html +66 -0
- package/preload.cjs +15 -0
- package/server/bin/bootstrap.sh +318 -0
- package/server/bin/demo-fixture.py +188 -0
- package/server/bin/doctor.sh +310 -0
- package/server/bin/entrypoint.sh +191 -0
- package/server/bin/lib-common.sh +308 -0
- package/server/bin/lib-limits.sh +18 -0
- package/server/bin/lib-settings.sh +37 -0
- package/server/bin/notify.sh +335 -0
- package/server/bin/poller-loop.sh +68 -0
- package/server/bin/pr-watch.sh +356 -0
- package/server/bin/profile-repo.sh +141 -0
- package/server/bin/rs_agree.py +163 -0
- package/server/bin/rs_assets.py +10 -0
- package/server/bin/rs_device_flow.py +225 -0
- package/server/bin/rs_devices.py +220 -0
- package/server/bin/rs_diff.py +260 -0
- package/server/bin/rs_learn.py +754 -0
- package/server/bin/rs_md.py +107 -0
- package/server/bin/rs_paths.py +285 -0
- package/server/bin/rs_personal.py +276 -0
- package/server/bin/rs_profile.py +1091 -0
- package/server/bin/rs_push.py +417 -0
- package/server/bin/rs_queue.py +791 -0
- package/server/bin/rs_review_body.py +133 -0
- package/server/bin/rs_rollup.py +382 -0
- package/server/bin/rs_settings.py +265 -0
- package/server/bin/rs_stack.py +76 -0
- package/server/bin/rs_state.py +184 -0
- package/server/bin/rs_webhook.py +250 -0
- package/server/bin/run-qa.sh +295 -0
- package/server/bin/run-review.sh +402 -0
- package/server/bin/server.py +6271 -0
- package/server/bin/static/app-c45f0338184b.css +2 -0
- package/server/bin/static/app-c45f0338184b.js +237 -0
- package/server/bin/static/assets.json +5 -0
- package/server/bin/static/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
- package/server/bin/static/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
- package/server/bin/static/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
- package/server/bin/static/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
- package/server/bin/static/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
- package/server/bin/static/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
- package/server/bin/static/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
- package/server/bin/static/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
- package/server/bin/static/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
- package/server/bin/static/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
- package/server/bin/static/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
- package/server/bin/static/icons/apple-touch-icon.png +0 -0
- package/server/bin/static/icons/icon-192.png +0 -0
- package/server/bin/static/icons/icon-512.png +0 -0
- package/server/bin/static/icons/maskable-192.png +0 -0
- package/server/bin/static/icons/maskable-512.png +0 -0
- package/server/bin/static/manifest.webmanifest +20 -0
- package/server/bin/static/offline.html +28 -0
- package/server/bin/static/sw.js +126 -0
- package/server/skills/examples/deep-depth-domain-example.md +68 -0
- package/server/skills/examples/team-default-domain-example.md +68 -0
- package/server/skills/global-review.md +58 -0
- package/server/skills/pr-qa-guide/SKILL.md +187 -0
- package/server/skills/pr-qa-guide/template.html +489 -0
- package/server/skills/pr-review/SKILL.md +129 -0
- package/server/skills/repo-profile/SKILL.md +67 -0
- package/server.js +140 -0
- package/tools.js +147 -0
- package/tools.json +30 -0
- package/tunnel.js +157 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wimukti
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ReviewStage
|
|
2
|
+
|
|
3
|
+
**Stage your PR review. Post it as yourself.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx reviewstage
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
A window opens and walks you through three steps — sign in with GitHub, connect your Claude
|
|
10
|
+
account, pick the repositories you review — then shows your queue. When someone requests your
|
|
11
|
+
review, the dock badge ticks up; click the PR, run a review on your own Claude plan, tick the
|
|
12
|
+
findings worth posting, and post them under your own GitHub name. Nothing posts until you click.
|
|
13
|
+
|
|
14
|
+
This package is the desktop app. It carries the ReviewStage server with it, runs it on a free
|
|
15
|
+
local port, and fetches the two command-line tools the server needs (`gh`, `jq`) once, pinned by
|
|
16
|
+
version and SHA-256, into `~/.reviewstage/bin`. Everything stays on your machine.
|
|
17
|
+
|
|
18
|
+
## You need
|
|
19
|
+
|
|
20
|
+
- **Node 20 or newer** (you have it if you installed Claude Code with npm).
|
|
21
|
+
- **Claude Code** on your PATH — `npm install -g @anthropic-ai/claude-code`. Reviews run on
|
|
22
|
+
*your* Claude account; the app never fetches Claude Code for you.
|
|
23
|
+
- **Git, Python 3, Bash, OpenSSL and curl** — present on every macOS (after
|
|
24
|
+
`xcode-select --install`) and on any Linux desktop.
|
|
25
|
+
- macOS 12+ (Apple Silicon or Intel) or Linux x64/arm64. Windows is on the roadmap.
|
|
26
|
+
|
|
27
|
+
No Apple notarisation dialog: the app is Electron installed by npm, the same way
|
|
28
|
+
`npx @nervekit/desktop` and other npm-distributed desktop apps work.
|
|
29
|
+
|
|
30
|
+
## Your phone
|
|
31
|
+
|
|
32
|
+
**ReviewStage menu → Enable phone access…** starts a Cloudflare quick tunnel to your laptop,
|
|
33
|
+
shows a QR code and the HTTPS address. Scan it, sign in once, and **Add to Home Screen**. From
|
|
34
|
+
Settings on the phone, turn on push: a review request on a watched repository notifies the
|
|
35
|
+
phone, and the queue, the findings and the Post button are the same app at phone width. The
|
|
36
|
+
address is public while the tunnel is up (anyone with it reaches your sign-in page, nothing
|
|
37
|
+
more); **Disable phone access** or quitting the app ends it.
|
|
38
|
+
|
|
39
|
+
## Where things live
|
|
40
|
+
|
|
41
|
+
Everything is under `~/.reviewstage`: `.env` (the app writes it: a random `RS_SECRET`,
|
|
42
|
+
`RS_PERSONAL=1`, `DRY_RUN=1`), `settings.json` (the repositories you picked), `bin/` (the fetched
|
|
43
|
+
tools), `state/` (every run, staged findings, posted-review records), `repos/` (one blobless
|
|
44
|
+
clone per repository). Delete the directory to start over. `DRY_RUN=1` is on by default:
|
|
45
|
+
everything works except the final write to GitHub, so you can compare its output with your own
|
|
46
|
+
reviews first; switch it off from the Settings page.
|
|
47
|
+
|
|
48
|
+
## Problems
|
|
49
|
+
|
|
50
|
+
- *"Claude Code is not installed"* — install it with the command above and relaunch.
|
|
51
|
+
- *"This machine is missing …"* — on macOS run `xcode-select --install`; on Linux install the
|
|
52
|
+
named packages.
|
|
53
|
+
- *A tool's checksum did not match* — the download is discarded and the app stops. Try again
|
|
54
|
+
later; if it persists, open an issue with the message.
|
|
55
|
+
- `npx reviewstage --doctor` prints one PASS / WARN / FAIL line per check without opening a
|
|
56
|
+
window; the server's output from the last launch is in `~/.reviewstage/server.log`.
|
|
57
|
+
|
|
58
|
+
Full documentation, the security model and the team install (one server, every reviewer as
|
|
59
|
+
themselves) are at <https://wimukti.github.io/reviewstage/>. Source and issues:
|
|
60
|
+
<https://github.com/Wimukti/reviewstage>. MIT.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npx reviewstage` lands here. The job is small: find the Electron binary npm installed next
|
|
3
|
+
// to this package and hand it main.js. Everything that matters happens in main.js.
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { statSync } from "node:fs";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
// `npx reviewstage --doctor` runs the server's own doctor against this install, no window:
|
|
15
|
+
// the same PASS/WARN/FAIL lines the team install gets, with the fetched tools on PATH.
|
|
16
|
+
if (process.argv.includes("--doctor")) {
|
|
17
|
+
const root = process.env.ROOT || join(homedir(), ".reviewstage");
|
|
18
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
19
|
+
const r = spawnSync("bash", [join(here, "..", "server", "bin", "doctor.sh")], {
|
|
20
|
+
stdio: "inherit",
|
|
21
|
+
env: { ...process.env, ROOT: root, RS_PERSONAL: process.env.RS_PERSONAL || "1", PATH: `${join(root, "bin")}${sep}${process.env.PATH || ""}` },
|
|
22
|
+
});
|
|
23
|
+
process.exit(r.status ?? 1);
|
|
24
|
+
}
|
|
25
|
+
let electron;
|
|
26
|
+
try {
|
|
27
|
+
electron = require("electron"); // the package's default export is the binary path
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error("ReviewStage: the Electron binary is missing. Reinstall with `npx reviewstage@latest`.");
|
|
30
|
+
console.error(String(e?.message || e));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
// Linux: Chromium's setuid sandbox helper has to be root-owned 4755, which nothing installed by
|
|
34
|
+
// npm into a user's home can be, and Ubuntu 24.04 also restricts the unprivileged-namespace
|
|
35
|
+
// fallback. Every npm-distributed Electron app hits this. The window only ever shows the local
|
|
36
|
+
// server (external links open in the system browser), so running without the Chromium sandbox
|
|
37
|
+
// is the documented trade on Linux; it is left on wherever the helper is usable.
|
|
38
|
+
const flags = [];
|
|
39
|
+
if (process.platform === "linux") {
|
|
40
|
+
try {
|
|
41
|
+
const st = statSync(join(dirname(electron), "chrome-sandbox"));
|
|
42
|
+
if (!(st.uid === 0 && (st.mode & 0o4000))) flags.push("--no-sandbox");
|
|
43
|
+
} catch { flags.push("--no-sandbox"); }
|
|
44
|
+
}
|
|
45
|
+
const child = spawn(electron, [join(here, "..", "main.js"), ...flags, ...process.argv.slice(2)], {
|
|
46
|
+
stdio: "inherit",
|
|
47
|
+
env: { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: "1" },
|
|
48
|
+
});
|
|
49
|
+
child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
package/flock.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""`flock` for macOS, which ships without it.
|
|
3
|
+
|
|
4
|
+
The review scripts use three shapes of util-linux flock, and nothing else:
|
|
5
|
+
|
|
6
|
+
flock -n 9 take an exclusive non-blocking lock on fd 9 (opened by the shell)
|
|
7
|
+
flock -w 3600 8 take an exclusive lock on fd 8, waiting up to 3600 s
|
|
8
|
+
flock -n FILE cmd … lock FILE non-blocking, run cmd under it, exit with cmd's status
|
|
9
|
+
|
|
10
|
+
Exit status 1 when a non-blocking or timed lock cannot be taken, as util-linux does, so
|
|
11
|
+
`flock -n 9 || exit 0` guards behave identically. Installed by the desktop launcher into
|
|
12
|
+
~/.reviewstage/bin, which it prepends to the server's PATH.
|
|
13
|
+
"""
|
|
14
|
+
import fcntl
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def usage():
|
|
22
|
+
sys.stderr.write("usage: flock [-n] [-w SECS] FD | flock [-n] [-w SECS] FILE CMD [ARG…]\n")
|
|
23
|
+
sys.exit(64)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def take(fd, nonblock, wait):
|
|
27
|
+
flags = fcntl.LOCK_EX
|
|
28
|
+
if nonblock:
|
|
29
|
+
flags |= fcntl.LOCK_NB
|
|
30
|
+
try:
|
|
31
|
+
fcntl.flock(fd, flags)
|
|
32
|
+
return True
|
|
33
|
+
except BlockingIOError:
|
|
34
|
+
return False
|
|
35
|
+
if wait is None:
|
|
36
|
+
fcntl.flock(fd, flags)
|
|
37
|
+
return True
|
|
38
|
+
deadline = time.monotonic() + wait
|
|
39
|
+
while True:
|
|
40
|
+
try:
|
|
41
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
42
|
+
return True
|
|
43
|
+
except BlockingIOError:
|
|
44
|
+
if time.monotonic() >= deadline:
|
|
45
|
+
return False
|
|
46
|
+
time.sleep(0.2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv):
|
|
50
|
+
nonblock, wait = False, None
|
|
51
|
+
i = 0
|
|
52
|
+
while i < len(argv) and argv[i].startswith("-"):
|
|
53
|
+
a = argv[i]
|
|
54
|
+
if a in ("-n", "--nonblock", "--nb"):
|
|
55
|
+
nonblock = True
|
|
56
|
+
elif a in ("-w", "--wait", "--timeout"):
|
|
57
|
+
i += 1
|
|
58
|
+
if i >= len(argv):
|
|
59
|
+
usage()
|
|
60
|
+
wait = float(argv[i])
|
|
61
|
+
elif a in ("-x", "--exclusive"):
|
|
62
|
+
pass
|
|
63
|
+
else:
|
|
64
|
+
usage()
|
|
65
|
+
i += 1
|
|
66
|
+
rest = argv[i:]
|
|
67
|
+
if not rest:
|
|
68
|
+
usage()
|
|
69
|
+
if len(rest) == 1 and rest[0].isdigit():
|
|
70
|
+
return 0 if take(int(rest[0]), nonblock, wait) else 1
|
|
71
|
+
path, cmd = rest[0], rest[1:]
|
|
72
|
+
if not cmd:
|
|
73
|
+
usage()
|
|
74
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
75
|
+
if not take(fd, nonblock, wait):
|
|
76
|
+
return 1
|
|
77
|
+
try:
|
|
78
|
+
return subprocess.call(cmd)
|
|
79
|
+
finally:
|
|
80
|
+
try:
|
|
81
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
82
|
+
finally:
|
|
83
|
+
os.close(fd)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main(sys.argv[1:]))
|
package/main.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// `npx reviewstage` — the desktop app. One window on the local server, which this process
|
|
2
|
+
// starts and owns. Everything the Docker install does with Compose and a browser happens here:
|
|
3
|
+
// a root with a secret, the tools the scripts need, the server on a free port, the queue count
|
|
4
|
+
// on the dock, a notification when a review arrives, and on request a public tunnel for the
|
|
5
|
+
// phone. See openspec/changes/npx-desktop/design.md.
|
|
6
|
+
import { app, BrowserWindow, ipcMain, Menu, Notification, shell, session } from "electron";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import QRCode from "qrcode";
|
|
11
|
+
import { ensureEnv, freePort, spawnServer, stopServer } from "./server.js";
|
|
12
|
+
import { ensureTools } from "./tools.js";
|
|
13
|
+
import { startTunnel, stopTunnel, tunnelStatus } from "./tunnel.js";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = process.env.ROOT || join(homedir(), ".reviewstage");
|
|
17
|
+
const SMOKE = process.env.RS_SMOKE === "1"; // the Playwright smoke test: no notifications, exit cleanly
|
|
18
|
+
|
|
19
|
+
let win = null;
|
|
20
|
+
let server = null; // { child, port }
|
|
21
|
+
let badgeTimer = null;
|
|
22
|
+
let lastTodo = -1;
|
|
23
|
+
let phoneWin = null;
|
|
24
|
+
let phoneBusy = false; // an enable or disable in flight; the menu items wait for it
|
|
25
|
+
|
|
26
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
27
|
+
const progress = (p) => {
|
|
28
|
+
if (win && !win.isDestroyed()) win.webContents.send("progress", p);
|
|
29
|
+
// In the smoke test an error would otherwise sit on the Retry screen forever.
|
|
30
|
+
if (SMOKE && p.error) { console.log(`RS_SMOKE_FAIL ${String(p.error).replace(/<[^>]+>/g, " ")}`); setTimeout(() => app.exit(2), 200); }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function createWindow() {
|
|
34
|
+
win = new BrowserWindow({
|
|
35
|
+
width: 1280,
|
|
36
|
+
height: 860,
|
|
37
|
+
minWidth: 390,
|
|
38
|
+
minHeight: 600,
|
|
39
|
+
title: "ReviewStage",
|
|
40
|
+
backgroundColor: "#0B0C10",
|
|
41
|
+
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
|
42
|
+
trafficLightPosition: { x: 14, y: 14 },
|
|
43
|
+
webPreferences: { preload: join(here, "preload.cjs"), contextIsolation: true, sandbox: false },
|
|
44
|
+
show: false,
|
|
45
|
+
});
|
|
46
|
+
win.once("ready-to-show", () => win.show());
|
|
47
|
+
// Links to GitHub and the docs open in the person's browser, not inside the app.
|
|
48
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
49
|
+
if (server && url.startsWith(`http://127.0.0.1:${server.port}`)) return { action: "allow" };
|
|
50
|
+
shell.openExternal(url);
|
|
51
|
+
return { action: "deny" };
|
|
52
|
+
});
|
|
53
|
+
win.on("closed", () => { win = null; });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function prepare() {
|
|
57
|
+
progress({ clearError: true });
|
|
58
|
+
progress({ step: "tools", state: "doing" });
|
|
59
|
+
let tools;
|
|
60
|
+
try {
|
|
61
|
+
tools = await ensureTools(ROOT, (fraction, label) => progress({ step: "tools", state: "doing", label, fraction }));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
progress({ step: "tools", state: "failed", label: "Review tools" });
|
|
64
|
+
progress({ error: `Could not fetch a tool this app needs.<br><code>${esc(e.message)}</code>` });
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
progress({ step: "tools", state: "done", label: "Review tools ready" });
|
|
68
|
+
|
|
69
|
+
const missing = tools.missing.filter((m) => m !== "claude");
|
|
70
|
+
if (missing.length) {
|
|
71
|
+
progress({ step: "claude", state: "failed" });
|
|
72
|
+
progress({ error: `This machine is missing <code>${esc(missing.join(", "))}</code>. On macOS, run <code>xcode-select --install</code>; on Linux, install them with your package manager.` });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (tools.missing.includes("claude")) {
|
|
76
|
+
progress({ step: "claude", state: "failed", label: "Claude Code is not installed" });
|
|
77
|
+
progress({ error: `ReviewStage runs reviews on <b>your</b> Claude account, through Claude Code. Install it, then try again:<br><code>npm install -g @anthropic-ai/claude-code</code>` });
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
progress({ step: "claude", state: "done", label: "Claude Code found" });
|
|
81
|
+
|
|
82
|
+
progress({ step: "server", state: "doing" });
|
|
83
|
+
const port = await freePort();
|
|
84
|
+
ensureEnv(ROOT, port);
|
|
85
|
+
try {
|
|
86
|
+
server = await spawnServer({ root: ROOT, binDir: tools.binDir, port });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
progress({ step: "server", state: "failed" });
|
|
89
|
+
progress({ error: `${esc(e.message)}<br><code>${esc((e.log || []).join("\n"))}</code>` });
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
progress({ step: "server", state: "done", label: "Server running" });
|
|
93
|
+
return server;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The window's own cookies for the local server as a Cookie header, or null when nobody is
|
|
97
|
+
* signed in. The badge and the public-URL call both act only as the signed-in person. */
|
|
98
|
+
async function sessionCookieHeader() {
|
|
99
|
+
if (!server) return null;
|
|
100
|
+
const cookies = await session.defaultSession.cookies.get({ url: `http://127.0.0.1:${server.port}` });
|
|
101
|
+
if (!cookies.some((c) => c.name === "rs_session")) return null;
|
|
102
|
+
return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pollBadge() {
|
|
106
|
+
if (!server || !win || win.isDestroyed()) return;
|
|
107
|
+
try {
|
|
108
|
+
const header = await sessionCookieHeader();
|
|
109
|
+
if (!header) { app.dock?.setBadge(""); return; }
|
|
110
|
+
const r = await fetch(`http://127.0.0.1:${server.port}/api/queue?tab=todo`, { headers: { Cookie: header } });
|
|
111
|
+
if (!r.ok) return;
|
|
112
|
+
const data = await r.json();
|
|
113
|
+
const todo = Array.isArray(data.rows) ? data.rows.length : Number(data.counts?.todo ?? 0);
|
|
114
|
+
app.dock?.setBadge(todo ? String(todo) : "");
|
|
115
|
+
if (process.platform !== "darwin") win.setTitle(todo ? `ReviewStage (${todo})` : "ReviewStage");
|
|
116
|
+
if (lastTodo >= 0 && todo > lastTodo && !SMOKE && Notification.isSupported()) {
|
|
117
|
+
const n = new Notification({ title: "Review requested", body: todo === 1 ? "1 pull request is waiting for you." : `${todo} pull requests are waiting for you.`, silent: false });
|
|
118
|
+
n.on("click", () => { win?.show(); win?.loadURL(`http://127.0.0.1:${server.port}/`); });
|
|
119
|
+
n.show();
|
|
120
|
+
}
|
|
121
|
+
lastTodo = todo;
|
|
122
|
+
} catch {
|
|
123
|
+
/* server mid-restart; the next tick catches up */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- Phone access ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** Tell the server where browsers now reach it (lane D1's `POST /api/public-url`). Returns a
|
|
130
|
+
* warning string when it could not, so the page can say links will still point at the laptop. */
|
|
131
|
+
async function setPublicUrl(url) {
|
|
132
|
+
if (!server) return "The server is not running.";
|
|
133
|
+
try {
|
|
134
|
+
const header = await sessionCookieHeader();
|
|
135
|
+
if (!header) return "Sign in on this computer first so notification links can use the phone address.";
|
|
136
|
+
const r = await fetch(`http://127.0.0.1:${server.port}/api/public-url`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/json", Cookie: header },
|
|
139
|
+
body: JSON.stringify({ url }),
|
|
140
|
+
});
|
|
141
|
+
if (r.ok) return null;
|
|
142
|
+
return r.status === 404
|
|
143
|
+
? "This server cannot switch its public address at runtime; links in notifications will point at the laptop until the app restarts."
|
|
144
|
+
: `The server refused the new public address (HTTP ${r.status}); links in notifications will point at the laptop until the app restarts.`;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return `Could not update the server's public address (${e.message}).`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function openPhoneWindow() {
|
|
151
|
+
if (phoneWin && !phoneWin.isDestroyed()) { phoneWin.show(); phoneWin.focus(); return phoneWin; }
|
|
152
|
+
phoneWin = new BrowserWindow({
|
|
153
|
+
width: 440,
|
|
154
|
+
height: 620,
|
|
155
|
+
resizable: false,
|
|
156
|
+
minimizable: false,
|
|
157
|
+
maximizable: false,
|
|
158
|
+
fullscreenable: false,
|
|
159
|
+
parent: win && !win.isDestroyed() ? win : undefined,
|
|
160
|
+
title: "Review from your phone",
|
|
161
|
+
backgroundColor: "#0B0C10",
|
|
162
|
+
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
|
163
|
+
trafficLightPosition: { x: 14, y: 14 },
|
|
164
|
+
webPreferences: { preload: join(here, "preload.cjs"), contextIsolation: true, sandbox: false },
|
|
165
|
+
show: false,
|
|
166
|
+
});
|
|
167
|
+
phoneWin.setMenuBarVisibility(false);
|
|
168
|
+
phoneWin.once("ready-to-show", () => phoneWin?.show());
|
|
169
|
+
phoneWin.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: "deny" }; });
|
|
170
|
+
phoneWin.on("closed", () => { phoneWin = null; refreshMenu(); });
|
|
171
|
+
phoneWin.loadFile(join(here, "pages", "phone.html"));
|
|
172
|
+
return phoneWin;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function sendPhoneData(data) {
|
|
176
|
+
if (!phoneWin || phoneWin.isDestroyed()) return;
|
|
177
|
+
if (phoneWin.webContents.isLoading()) await new Promise((r) => phoneWin.webContents.once("did-finish-load", r));
|
|
178
|
+
if (phoneWin && !phoneWin.isDestroyed()) phoneWin.webContents.send("phone", data);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function enablePhone() {
|
|
182
|
+
if (!server) return { enabled: false, error: "The server is not running." };
|
|
183
|
+
if (phoneBusy) return { ...tunnelStatus(), busy: true };
|
|
184
|
+
phoneBusy = true;
|
|
185
|
+
refreshMenu();
|
|
186
|
+
try {
|
|
187
|
+
const was = tunnelStatus();
|
|
188
|
+
openPhoneWindow();
|
|
189
|
+
const { url } = was.enabled ? was : await startTunnel(server.port, { root: ROOT });
|
|
190
|
+
const warning = was.enabled ? null : await setPublicUrl(url);
|
|
191
|
+
const dataUrl = await QRCode.toDataURL(url, { margin: 1, width: 280, color: { dark: "#ECEEF3", light: "#0B0C10" } });
|
|
192
|
+
await sendPhoneData({ url, dataUrl, warning });
|
|
193
|
+
return { enabled: true, url, warning };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
const detail = (e.log || []).join("\n");
|
|
196
|
+
await sendPhoneData({ warning: `Could not open a tunnel: ${esc(e.message)}` });
|
|
197
|
+
console.error(`phone access: ${e.message}\n${detail}`);
|
|
198
|
+
return { enabled: false, error: e.message, log: e.log || [] };
|
|
199
|
+
} finally {
|
|
200
|
+
phoneBusy = false;
|
|
201
|
+
refreshMenu();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function disablePhone() {
|
|
206
|
+
if (phoneBusy) return { ...tunnelStatus(), busy: true };
|
|
207
|
+
phoneBusy = true;
|
|
208
|
+
refreshMenu();
|
|
209
|
+
try {
|
|
210
|
+
await stopTunnel();
|
|
211
|
+
if (server) await setPublicUrl(`http://127.0.0.1:${server.port}`);
|
|
212
|
+
if (phoneWin && !phoneWin.isDestroyed()) phoneWin.close();
|
|
213
|
+
return { enabled: false, url: null };
|
|
214
|
+
} finally {
|
|
215
|
+
phoneBusy = false;
|
|
216
|
+
refreshMenu();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function phoneMenuItems() {
|
|
221
|
+
const { enabled } = tunnelStatus();
|
|
222
|
+
const items = enabled
|
|
223
|
+
? [{ label: "Disable phone access", enabled: !phoneBusy, click: () => void disablePhone() }]
|
|
224
|
+
: [{ label: "Enable phone access…", enabled: !phoneBusy && !!server, click: () => void enablePhone() }];
|
|
225
|
+
if (enabled && (!phoneWin || phoneWin.isDestroyed())) {
|
|
226
|
+
items.unshift({ label: "Show phone access code…", enabled: !phoneBusy, click: () => void enablePhone() });
|
|
227
|
+
}
|
|
228
|
+
return items;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function refreshMenu() {
|
|
232
|
+
const isMac = process.platform === "darwin";
|
|
233
|
+
const template = [
|
|
234
|
+
isMac
|
|
235
|
+
? { label: app.name, submenu: [{ role: "about" }, { type: "separator" }, ...phoneMenuItems(), { type: "separator" }, { role: "services" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }] }
|
|
236
|
+
: { label: "File", submenu: [...phoneMenuItems(), { type: "separator" }, { role: "quit" }] },
|
|
237
|
+
{ role: "editMenu" },
|
|
238
|
+
{ role: "viewMenu" },
|
|
239
|
+
{ role: "windowMenu" },
|
|
240
|
+
];
|
|
241
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
|
242
|
+
if (isMac) app.dock?.setMenu(Menu.buildFromTemplate(phoneMenuItems()));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function boot() {
|
|
246
|
+
createWindow();
|
|
247
|
+
await win.loadFile(join(here, "pages", "preparing.html"));
|
|
248
|
+
const ok = await prepare();
|
|
249
|
+
refreshMenu(); // "Enable phone access" is available once the server is
|
|
250
|
+
if (!ok) return; // the page shows the error and a Retry
|
|
251
|
+
await win.loadURL(`http://127.0.0.1:${server.port}/`);
|
|
252
|
+
pollBadge();
|
|
253
|
+
badgeTimer = setInterval(pollBadge, 60_000);
|
|
254
|
+
if (SMOKE) {
|
|
255
|
+
// Prove the whole path once, then leave so the test can assert on stdout.
|
|
256
|
+
console.log(`RS_SMOKE_OK port=${server.port} title=${win.getTitle()}`);
|
|
257
|
+
setTimeout(() => app.quit(), 500);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
ipcMain.on("retry", () => void boot());
|
|
262
|
+
ipcMain.on("quit", () => app.quit());
|
|
263
|
+
ipcMain.handle("phone:enable", () => enablePhone());
|
|
264
|
+
ipcMain.handle("phone:disable", () => disablePhone());
|
|
265
|
+
ipcMain.handle("phone:status", () => { const { enabled, url } = tunnelStatus(); return { enabled, url }; });
|
|
266
|
+
|
|
267
|
+
app.setName("ReviewStage");
|
|
268
|
+
app.whenReady().then(() => { refreshMenu(); return boot(); });
|
|
269
|
+
app.on("activate", () => { if (!win) createWindow(); });
|
|
270
|
+
app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });
|
|
271
|
+
app.on("before-quit", async (e) => {
|
|
272
|
+
if (!server) return;
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
clearInterval(badgeTimer);
|
|
275
|
+
const child = server.child;
|
|
276
|
+
server = null;
|
|
277
|
+
// The tunnel first: once it is gone the public address answers nothing, then the server.
|
|
278
|
+
await stopTunnel();
|
|
279
|
+
await stopServer(child);
|
|
280
|
+
app.exit(0);
|
|
281
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reviewstage",
|
|
3
|
+
"version": "1.0.0-rc.24",
|
|
4
|
+
"description": "Stage your PR review. Post it as yourself. The desktop app — `npx reviewstage`.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://wimukti.github.io/reviewstage/",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Wimukti/reviewstage.git",
|
|
10
|
+
"directory": "desktop"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"reviewstage": "./bin/reviewstage.js"
|
|
14
|
+
},
|
|
15
|
+
"main": "main.js",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"main.js",
|
|
23
|
+
"preload.cjs",
|
|
24
|
+
"tools.json",
|
|
25
|
+
"tools.js",
|
|
26
|
+
"flock.py",
|
|
27
|
+
"server.js",
|
|
28
|
+
"tunnel.js",
|
|
29
|
+
"pages/",
|
|
30
|
+
"server/"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"electron": "44.5.1",
|
|
34
|
+
"qrcode": "^1.5.4"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@playwright/test": "^1.63.0"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"start": "node bin/reviewstage.js",
|
|
41
|
+
"test": "node --test test/*.test.mjs",
|
|
42
|
+
"test:smoke": "node --test test/smoke.test.mjs"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/pages/phone.html
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Review from your phone</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { --paper:#0B0C10; --panel:#12141A; --ink:#ECEEF3; --graphite:#9AA0B4; --hairline:#22262F; --blue:#7A83FF; --red:#F0718A; --green:#3DD98F; --light: rgba(255,225,176,.17); }
|
|
8
|
+
html, body { height: 100%; margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.5 "Geist Variable", system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-app-region: drag; }
|
|
9
|
+
body { display: grid; place-items: center; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
|
10
|
+
body::-webkit-scrollbar { display: none }
|
|
11
|
+
.card { position: relative; width: min(400px, calc(100vw - 32px)); margin: 30px 0 10px; padding: 16px 22px 14px; border-radius: 12px; background: var(--panel); box-shadow: 0 24px 64px -24px rgba(0,0,0,.8); -webkit-app-region: no-drag; overflow: hidden; }
|
|
12
|
+
.card::before { content: ""; position: absolute; left: 50%; top: -46%; width: 120%; height: 112%; transform: translateX(-50%); border-radius: 50%; background: radial-gradient(60% 55% at 50% 0%, var(--light), transparent 70%); pointer-events: none; animation: breathe 8s ease-in-out infinite alternate; }
|
|
13
|
+
@keyframes breathe { from { opacity: .7 } to { opacity: 1 } }
|
|
14
|
+
@media (prefers-reduced-motion: reduce) { .card::before { animation: none } }
|
|
15
|
+
h1 { position: relative; margin: 0 0 10px; font-size: 20px; font-weight: 600; letter-spacing: -0.02em; text-align: center; }
|
|
16
|
+
.qr { position: relative; display: block; width: 224px; height: 224px; margin: 0 auto; border-radius: 10px; background: var(--paper); image-rendering: pixelated; }
|
|
17
|
+
.qr[hidden] { display: none }
|
|
18
|
+
.wait { position: relative; height: 224px; display: grid; place-items: center; color: var(--graphite); }
|
|
19
|
+
.wait[hidden] { display: none }
|
|
20
|
+
.url { position: relative; display: flex; align-items: center; gap: 8px; margin: 10px 0 8px; padding: 5px 5px 5px 10px; border: 1px solid var(--hairline); border-radius: 8px; background: var(--paper); }
|
|
21
|
+
.url code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: all; -webkit-user-select: all; font: 12px/1.4 "Geist Mono Variable", ui-monospace, Menlo, monospace; color: var(--ink); }
|
|
22
|
+
ol { position: relative; margin: 0; padding: 0 0 0 20px; color: var(--graphite); font-size: 12.5px; line-height: 1.4; }
|
|
23
|
+
ol li { padding: 1px 0; }
|
|
24
|
+
ol li::marker { color: var(--blue); font-weight: 600; }
|
|
25
|
+
.fine { position: relative; margin: 6px 0 0; color: var(--graphite); font-size: 11.5px; line-height: 1.4; }
|
|
26
|
+
.warn { position: relative; margin: 6px 0 0; color: var(--red); font-size: 11.5px; line-height: 1.4; }
|
|
27
|
+
.warn:empty { display: none }
|
|
28
|
+
.fine a { color: var(--graphite); text-decoration: underline; text-underline-offset: 2px; }
|
|
29
|
+
.acts { position: relative; display: flex; justify-content: flex-end; margin-top: 8px; }
|
|
30
|
+
button { font: inherit; font-weight: 500; border: 0; border-radius: 8px; padding: 8px 14px; cursor: pointer; background: var(--blue); color: var(--paper); }
|
|
31
|
+
button.ghost { background: transparent; color: var(--graphite); border: 1px solid var(--hairline); }
|
|
32
|
+
button.small { padding: 5px 10px; font-size: 12px; flex: none; }
|
|
33
|
+
button:disabled { opacity: .6; cursor: default; }
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div class="card">
|
|
38
|
+
<h1>Review from your phone</h1>
|
|
39
|
+
<div class="wait" id="wait">Opening a tunnel…</div>
|
|
40
|
+
<img class="qr" id="qr" alt="QR code of the phone address" hidden>
|
|
41
|
+
<div class="url"><code id="url" title="">…</code><button class="small" id="copy" disabled>Copy</button></div>
|
|
42
|
+
<ol>
|
|
43
|
+
<li>Scan this with your phone's camera</li>
|
|
44
|
+
<li>Sign in, then Share → Add to Home Screen</li>
|
|
45
|
+
<li>Open it from the home screen and turn on notifications in Settings</li>
|
|
46
|
+
</ol>
|
|
47
|
+
<p class="warn" id="warn"></p>
|
|
48
|
+
<p class="fine" id="fine">This address is public while phone access is on; every action still needs your sign-in. It stops when you turn phone access off or quit. <a href="https://wimukti.github.io/reviewstage/security/" target="_blank" rel="noopener">How sign-in and links are protected</a></p>
|
|
49
|
+
<div class="acts"><button class="ghost" id="disable">Disable phone access</button></div>
|
|
50
|
+
</div>
|
|
51
|
+
<script>
|
|
52
|
+
const $ = (s) => document.querySelector(s);
|
|
53
|
+
let url = "";
|
|
54
|
+
window.reviewstage?.phone.onData((p) => {
|
|
55
|
+
if (p.url) {
|
|
56
|
+
url = p.url;
|
|
57
|
+
$("#url").textContent = url;
|
|
58
|
+
$("#url").title = url;
|
|
59
|
+
$("#copy").disabled = false;
|
|
60
|
+
}
|
|
61
|
+
if (p.dataUrl) {
|
|
62
|
+
$("#qr").src = p.dataUrl;
|
|
63
|
+
$("#qr").hidden = false;
|
|
64
|
+
$("#wait").hidden = true;
|
|
65
|
+
}
|
|
66
|
+
if (p.warning) $("#warn").textContent = p.warning;
|
|
67
|
+
});
|
|
68
|
+
$("#copy").onclick = async () => {
|
|
69
|
+
try { await navigator.clipboard.writeText(url); } catch {
|
|
70
|
+
const r = document.createRange(); r.selectNodeContents($("#url"));
|
|
71
|
+
const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); document.execCommand("copy");
|
|
72
|
+
}
|
|
73
|
+
$("#copy").textContent = "Copied";
|
|
74
|
+
setTimeout(() => { $("#copy").textContent = "Copy"; }, 2000);
|
|
75
|
+
};
|
|
76
|
+
$("#disable").onclick = () => { $("#disable").disabled = true; $("#disable").textContent = "Turning off…"; window.reviewstage?.phone.disable(); };
|
|
77
|
+
</script>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|