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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/bin/reviewstage.js +49 -0
  4. package/flock.py +87 -0
  5. package/main.js +281 -0
  6. package/package.json +44 -0
  7. package/pages/phone.html +79 -0
  8. package/pages/preparing.html +66 -0
  9. package/preload.cjs +15 -0
  10. package/server/bin/bootstrap.sh +318 -0
  11. package/server/bin/demo-fixture.py +188 -0
  12. package/server/bin/doctor.sh +310 -0
  13. package/server/bin/entrypoint.sh +191 -0
  14. package/server/bin/lib-common.sh +308 -0
  15. package/server/bin/lib-limits.sh +18 -0
  16. package/server/bin/lib-settings.sh +37 -0
  17. package/server/bin/notify.sh +335 -0
  18. package/server/bin/poller-loop.sh +68 -0
  19. package/server/bin/pr-watch.sh +356 -0
  20. package/server/bin/profile-repo.sh +141 -0
  21. package/server/bin/rs_agree.py +163 -0
  22. package/server/bin/rs_assets.py +10 -0
  23. package/server/bin/rs_device_flow.py +225 -0
  24. package/server/bin/rs_devices.py +220 -0
  25. package/server/bin/rs_diff.py +260 -0
  26. package/server/bin/rs_learn.py +754 -0
  27. package/server/bin/rs_md.py +107 -0
  28. package/server/bin/rs_paths.py +285 -0
  29. package/server/bin/rs_personal.py +276 -0
  30. package/server/bin/rs_profile.py +1091 -0
  31. package/server/bin/rs_push.py +417 -0
  32. package/server/bin/rs_queue.py +791 -0
  33. package/server/bin/rs_review_body.py +133 -0
  34. package/server/bin/rs_rollup.py +382 -0
  35. package/server/bin/rs_settings.py +265 -0
  36. package/server/bin/rs_stack.py +76 -0
  37. package/server/bin/rs_state.py +184 -0
  38. package/server/bin/rs_webhook.py +250 -0
  39. package/server/bin/run-qa.sh +295 -0
  40. package/server/bin/run-review.sh +402 -0
  41. package/server/bin/server.py +6271 -0
  42. package/server/bin/static/app-c45f0338184b.css +2 -0
  43. package/server/bin/static/app-c45f0338184b.js +237 -0
  44. package/server/bin/static/assets.json +5 -0
  45. package/server/bin/static/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
  46. package/server/bin/static/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
  47. package/server/bin/static/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
  48. package/server/bin/static/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
  49. package/server/bin/static/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
  50. package/server/bin/static/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
  51. package/server/bin/static/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
  52. package/server/bin/static/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
  53. package/server/bin/static/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
  54. package/server/bin/static/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
  55. package/server/bin/static/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
  56. package/server/bin/static/icons/apple-touch-icon.png +0 -0
  57. package/server/bin/static/icons/icon-192.png +0 -0
  58. package/server/bin/static/icons/icon-512.png +0 -0
  59. package/server/bin/static/icons/maskable-192.png +0 -0
  60. package/server/bin/static/icons/maskable-512.png +0 -0
  61. package/server/bin/static/manifest.webmanifest +20 -0
  62. package/server/bin/static/offline.html +28 -0
  63. package/server/bin/static/sw.js +126 -0
  64. package/server/skills/examples/deep-depth-domain-example.md +68 -0
  65. package/server/skills/examples/team-default-domain-example.md +68 -0
  66. package/server/skills/global-review.md +58 -0
  67. package/server/skills/pr-qa-guide/SKILL.md +187 -0
  68. package/server/skills/pr-qa-guide/template.html +489 -0
  69. package/server/skills/pr-review/SKILL.md +129 -0
  70. package/server/skills/repo-profile/SKILL.md +67 -0
  71. package/server.js +140 -0
  72. package/tools.js +147 -0
  73. package/tools.json +30 -0
  74. 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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
+ }
@@ -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>