@fullstackhouse/cerber 0.28.0 → 0.29.1
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/README.md +9 -3
- package/assets/cerber-icon.svg +21 -0
- package/assets/cerber.icns +0 -0
- package/dist/cli/index.js +8 -1
- package/dist/core/artifact.js +13 -0
- package/dist/core/history.js +6 -0
- package/dist/core/notify.js +313 -6
- package/dist/core/revise.js +3 -0
- package/dist/core/send.js +10 -1
- package/dist/runner/review.js +3 -0
- package/dist/server/daemon.js +7 -1
- package/dist/server/index.js +50 -0
- package/package.json +2 -1
- package/web/dist/assets/{index-CiQjqBau.css → index-C9jlO49c.css} +1 -1
- package/web/dist/assets/index-D0STDydg.js +241 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bztcz5eI.js +0 -241
package/README.md
CHANGED
|
@@ -96,9 +96,15 @@ running. It goes to whatever this machine already has (Notification Centre on
|
|
|
96
96
|
macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
|
|
97
97
|
`daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
**On macOS, clicking it opens that review** in the cockpit. It takes a small app
|
|
100
|
+
cerber builds once under `~/.cerber` — the first tap asks you to allow
|
|
101
|
+
notifications from "Cerber" — because a notification can only open the app that
|
|
102
|
+
posted it, and one posted the usual way belongs to Script Editor, which is
|
|
103
|
+
where the click would otherwise land. A `notify-send` tap on Linux announces
|
|
104
|
+
the PR but has no click to give.
|
|
105
|
+
|
|
106
|
+
The cockpit has a bell of its own, which does the same from the page. But it is
|
|
107
|
+
a page notification — it needs a tab that is open, alive and permitted, which is
|
|
102
108
|
exactly what you don't have on the afternoons this feature is for. So it stands
|
|
103
109
|
down while the machine's own tap is on, rather than making one PR two popups,
|
|
104
110
|
and the bell in the top bar says so. Untick the machine one and this browser
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
|
2
|
+
<!-- The app icon for the notifier bundle cerber builds under ~/.cerber, and
|
|
3
|
+
the source the committed assets/cerber.icns is rendered from (see the
|
|
4
|
+
note in src/core/notify.ts). Same paw as web/public/favicon.svg — an app
|
|
5
|
+
icon is a squircle on the shelf, so the mark is set on the brand tile
|
|
6
|
+
rather than floated on nothing, which at notification size reads as a
|
|
7
|
+
missing icon. -->
|
|
8
|
+
<defs>
|
|
9
|
+
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
|
10
|
+
<stop offset="0" stop-color="#5f6ef2" />
|
|
11
|
+
<stop offset="1" stop-color="#2f3bbd" />
|
|
12
|
+
</linearGradient>
|
|
13
|
+
</defs>
|
|
14
|
+
<rect x="100" y="100" width="824" height="824" rx="185" fill="url(#tile)" />
|
|
15
|
+
<g fill="#fff" transform="translate(200 188) scale(26)">
|
|
16
|
+
<circle cx="6.1" cy="8.9" r="2.5" />
|
|
17
|
+
<circle cx="12" cy="6.7" r="2.7" />
|
|
18
|
+
<circle cx="17.9" cy="8.9" r="2.5" />
|
|
19
|
+
<ellipse cx="12" cy="16.6" rx="5.4" ry="4.3" />
|
|
20
|
+
</g>
|
|
21
|
+
</svg>
|
|
Binary file
|
package/dist/cli/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunn
|
|
|
13
13
|
import { pool, reviewPr } from "../runner/review.js";
|
|
14
14
|
import { isReviewRunning } from "../runner/inflight.js";
|
|
15
15
|
import { startDaemon } from "../server/daemon.js";
|
|
16
|
-
import { startServer } from "../server/index.js";
|
|
16
|
+
import { cockpitUrl, startServer } from "../server/index.js";
|
|
17
17
|
const program = new Command();
|
|
18
18
|
program
|
|
19
19
|
.name("cerber")
|
|
@@ -415,6 +415,13 @@ program
|
|
|
415
415
|
trust: opts.trust ? undefined : false,
|
|
416
416
|
autoReview: opts.autoReview,
|
|
417
417
|
notify: opts.notify,
|
|
418
|
+
// The address as asked for, which the server corrects to the one it
|
|
419
|
+
// bound (`cockpitAt`) a moment later. Both, because neither alone
|
|
420
|
+
// is enough: the daemon's first poll runs while the port is still
|
|
421
|
+
// being bound, and a PR discovered on it would otherwise be
|
|
422
|
+
// announced with nowhere to click — while `--port 0` has no
|
|
423
|
+
// answer here at all, since the OS has not chosen yet.
|
|
424
|
+
cockpitUrl: cockpitUrl({ host: opts.host, port: Number(opts.port), token }) ?? undefined,
|
|
418
425
|
autoSend: opts.autoSend ? "on" : "shadow",
|
|
419
426
|
autoSendThreshold: threshold,
|
|
420
427
|
})
|
package/dist/core/artifact.js
CHANGED
|
@@ -274,6 +274,19 @@ export const ArtifactSchema = z.object({
|
|
|
274
274
|
chapters: z.array(ChapterSchema).default([]),
|
|
275
275
|
comments: z.array(CommentSchema).default([]),
|
|
276
276
|
verdict: VerdictSchema.nullable().default(null),
|
|
277
|
+
/**
|
|
278
|
+
* The review body to post, written by hand. Null — the normal case — means
|
|
279
|
+
* the body is composed from the draft at send time (§14.4: summary,
|
|
280
|
+
* walkthrough, the comments that cannot post inline, footer).
|
|
281
|
+
*
|
|
282
|
+
* Everywhere else in cerber, what GitHub gets is derived from what the
|
|
283
|
+
* cockpit shows. This is the one place the user can cut that link, so it is
|
|
284
|
+
* a field rather than a rewrite of the summary: the draft underneath stays
|
|
285
|
+
* exactly as the review wrote it, the body can always be built again, and
|
|
286
|
+
* the send panel says which of the two is about to be posted. A re-review
|
|
287
|
+
* clears it — it described a body for a draft that no longer exists.
|
|
288
|
+
*/
|
|
289
|
+
bodyOverride: z.string().nullable().default(null),
|
|
277
290
|
run: RunInfoSchema.nullable().default(null),
|
|
278
291
|
/** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
|
|
279
292
|
sent: SentInfoSchema.nullable().default(null),
|
package/dist/core/history.js
CHANGED
|
@@ -161,6 +161,12 @@ export function describeChange(before, after) {
|
|
|
161
161
|
lines.push(`verdict ${wasVerdict ? "changed to" : "set to"} ${verdict.recommendation.replace("_", " ")}` +
|
|
162
162
|
` (${verdict.confidence}% sure of the findings)`);
|
|
163
163
|
}
|
|
164
|
+
// What posts stopped following the draft, or started following it again.
|
|
165
|
+
if ((before?.bodyOverride ?? null) !== (after.bodyOverride ?? null)) {
|
|
166
|
+
lines.push(after.bodyOverride == null
|
|
167
|
+
? "the body to post follows the review again"
|
|
168
|
+
: "the body to post was written by hand");
|
|
169
|
+
}
|
|
164
170
|
const comments = describeComments(before?.comments ?? [], after.comments);
|
|
165
171
|
if (comments)
|
|
166
172
|
lines.push(comments);
|
package/dist/core/notify.js
CHANGED
|
@@ -7,11 +7,26 @@
|
|
|
7
7
|
// all afternoon. This half rides the poll that discovers the PR instead, so the
|
|
8
8
|
// tap survives a closed cockpit, a denied permission and a browser restart.
|
|
9
9
|
//
|
|
10
|
+
// A click has to land in the same place either way, which on macOS is the whole
|
|
11
|
+
// difference between the two paths below. `osascript -e 'display notification'`
|
|
12
|
+
// posts as Script Editor — that is the app macOS sees making the call — so
|
|
13
|
+
// clicking the tap opens Script Editor, an app the user never asked for and a
|
|
14
|
+
// dead end from a notification about a PR. A notification can only open what
|
|
15
|
+
// posted it, so cerber posts its own: a small app bundle built once under
|
|
16
|
+
// `~/.cerber`, which opens the review in the cockpit when clicked. Where it
|
|
17
|
+
// can't be built, the plain `osascript` tap is still better than silence.
|
|
18
|
+
//
|
|
10
19
|
// Everything here shells out to whatever the OS already has. No dependency, no
|
|
11
20
|
// daemon of its own, and a machine with no notifier is a machine cerber stays
|
|
12
21
|
// quiet on rather than one it fails on.
|
|
13
22
|
import { execFile } from "node:child_process";
|
|
23
|
+
import { promises as fs } from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
14
27
|
import { promisify } from "node:util";
|
|
28
|
+
import { artifactId, artifactKey } from "./artifact.js";
|
|
29
|
+
import { cerberHome } from "./state.js";
|
|
15
30
|
const execFileAsync = promisify(execFile);
|
|
16
31
|
/**
|
|
17
32
|
* How long a notifier gets before the poll stops waiting for it.
|
|
@@ -25,23 +40,42 @@ const execFileAsync = promisify(execFile);
|
|
|
25
40
|
*/
|
|
26
41
|
export const NOTIFY_TIMEOUT_MS = 5_000;
|
|
27
42
|
const slug = (a) => `${a.repo}#${a.number}`;
|
|
43
|
+
/**
|
|
44
|
+
* Text a notification body or an AppleScript literal can actually hold. A PR
|
|
45
|
+
* title is somebody else's text and can carry anything; control characters
|
|
46
|
+
* collapse to a space rather than splitting a line the applet reads back by
|
|
47
|
+
* line, or ending a string literal early.
|
|
48
|
+
*/
|
|
49
|
+
const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
28
50
|
/**
|
|
29
51
|
* One popup for one poll's arrivals — a batch is one interruption, not five.
|
|
30
52
|
* Deliberately the same shape and wording the cockpit's bell uses, so the two
|
|
31
53
|
* channels never read as two different pieces of news about one PR.
|
|
54
|
+
*
|
|
55
|
+
* `cockpit` is where clicking it lands: the review itself when the notice is
|
|
56
|
+
* about exactly one PR, the queue when it is about several. It must be an
|
|
57
|
+
* origin with no fragment of its own — the deep link is a fragment, and the
|
|
58
|
+
* cockpit is a hash-routed page.
|
|
32
59
|
*/
|
|
33
|
-
export function notice(arrived) {
|
|
60
|
+
export function notice(arrived, cockpit) {
|
|
34
61
|
if (arrived.length === 0)
|
|
35
62
|
return null;
|
|
63
|
+
const base = cockpit || null;
|
|
36
64
|
if (arrived.length === 1) {
|
|
37
65
|
const a = arrived[0];
|
|
38
|
-
|
|
66
|
+
const key = artifactKey(artifactId(a));
|
|
67
|
+
return {
|
|
68
|
+
title: `${slug(a)} awaits your review`,
|
|
69
|
+
body: `${a.title} — ${a.author}`,
|
|
70
|
+
url: base && `${base}#/r/${encodeURIComponent(key)}`,
|
|
71
|
+
};
|
|
39
72
|
}
|
|
40
73
|
const named = arrived.slice(0, 3).map(slug);
|
|
41
74
|
const rest = arrived.length - named.length;
|
|
42
75
|
return {
|
|
43
76
|
title: `${arrived.length} PRs await your review`,
|
|
44
77
|
body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
|
|
78
|
+
url: base,
|
|
45
79
|
};
|
|
46
80
|
}
|
|
47
81
|
/**
|
|
@@ -54,13 +88,15 @@ export function notice(arrived) {
|
|
|
54
88
|
* can't appear in a literal at all, so they collapse to a space.
|
|
55
89
|
*/
|
|
56
90
|
export function appleScriptLiteral(value) {
|
|
57
|
-
|
|
58
|
-
return `"${flat.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
91
|
+
return `"${flatten(value).replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
59
92
|
}
|
|
60
93
|
/**
|
|
61
94
|
* The command that taps this machine's notification centre, or null where there
|
|
62
95
|
* is nothing to tap. macOS and the freedesktop notifiers cover what cerber runs
|
|
63
96
|
* on; anywhere else it stays quiet rather than pretending.
|
|
97
|
+
*
|
|
98
|
+
* On macOS this is the fallback rather than the first choice — see
|
|
99
|
+
* `ensureNotifierApp` for the one whose click opens the review.
|
|
64
100
|
*/
|
|
65
101
|
export function notifyCommand(n, platform = process.platform) {
|
|
66
102
|
if (platform === "darwin") {
|
|
@@ -83,6 +119,275 @@ export function notifyCommand(n, platform = process.platform) {
|
|
|
83
119
|
}
|
|
84
120
|
return null;
|
|
85
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* What the notification centre knows cerber by. Permission is granted to this
|
|
124
|
+
* identifier, once, by the user — so it has to survive every rebuild, or each
|
|
125
|
+
* new version of cerber would ask for notifications again as a stranger.
|
|
126
|
+
*/
|
|
127
|
+
export const BUNDLE_ID = "house.fullstack.cerber";
|
|
128
|
+
/** Bumped whenever the applet or its plist changes, so an installed app is replaced. */
|
|
129
|
+
const APP_BUILD = "3";
|
|
130
|
+
const ICON = fileURLToPath(new URL("../../assets/cerber.icns", import.meta.url));
|
|
131
|
+
const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
132
|
+
/**
|
|
133
|
+
* The app's whole job, in the only language a bundle this small can be written
|
|
134
|
+
* in without a compiler on the user's machine.
|
|
135
|
+
*
|
|
136
|
+
* It is launched twice per notification and has to tell the two apart with no
|
|
137
|
+
* arguments, because a click gives it none: macOS just opens the app that
|
|
138
|
+
* posted the notification. The pending file is what distinguishes them, and
|
|
139
|
+
* reading it is destructive — a launch that finds a notice posts it, a launch
|
|
140
|
+
* that finds none is the click on the last one, and opens where that one
|
|
141
|
+
* pointed. Nothing else can put the app in either state, so the two can't be
|
|
142
|
+
* confused.
|
|
143
|
+
*
|
|
144
|
+
* The paths are baked in rather than derived from the home folder, so a cerber
|
|
145
|
+
* running under CERBER_HOME talks to the app it actually built.
|
|
146
|
+
*/
|
|
147
|
+
export function appletSource(dir) {
|
|
148
|
+
const pending = appleScriptLiteral(path.join(dir, "pending.txt"));
|
|
149
|
+
const landing = appleScriptLiteral(path.join(dir, "url.txt"));
|
|
150
|
+
// `on run` is a launch from cold, `on reopen` a click while it is still up
|
|
151
|
+
// after posting — same job either way.
|
|
152
|
+
return `on run
|
|
153
|
+
tap()
|
|
154
|
+
end run
|
|
155
|
+
|
|
156
|
+
on reopen
|
|
157
|
+
tap()
|
|
158
|
+
end reopen
|
|
159
|
+
|
|
160
|
+
on tap()
|
|
161
|
+
set pendingFile to ${pending}
|
|
162
|
+
set urlFile to ${landing}
|
|
163
|
+
try
|
|
164
|
+
set raw to do shell script "cat " & quoted form of pendingFile & " 2>/dev/null; rm -f " & quoted form of pendingFile
|
|
165
|
+
on error
|
|
166
|
+
set raw to ""
|
|
167
|
+
end try
|
|
168
|
+
if raw is "" then
|
|
169
|
+
try
|
|
170
|
+
set target to do shell script "cat " & quoted form of urlFile & " 2>/dev/null"
|
|
171
|
+
on error
|
|
172
|
+
set target to ""
|
|
173
|
+
end try
|
|
174
|
+
-- Consumed on the way out, the same way the notice was: what is left
|
|
175
|
+
-- behind is what cerber reads as "an earlier notification is still
|
|
176
|
+
-- sitting there unclicked".
|
|
177
|
+
if target is not "" then
|
|
178
|
+
do shell script "open " & quoted form of target & "; rm -f " & quoted form of urlFile
|
|
179
|
+
else
|
|
180
|
+
do shell script "rm -f " & quoted form of urlFile
|
|
181
|
+
end if
|
|
182
|
+
return
|
|
183
|
+
end if
|
|
184
|
+
-- do shell script hands back the lines separated by return, not linefeed.
|
|
185
|
+
set AppleScript's text item delimiters to return
|
|
186
|
+
set parts to text items of raw
|
|
187
|
+
if (count of parts) < 2 then return
|
|
188
|
+
set target to ""
|
|
189
|
+
if (count of parts) > 2 then set target to item 3 of parts
|
|
190
|
+
-- umask, because the target carries the cockpit's token: nothing here is
|
|
191
|
+
-- another account's business to read.
|
|
192
|
+
do shell script "umask 077; printf %s " & quoted form of target & " > " & quoted form of urlFile
|
|
193
|
+
display notification (item 2 of parts) with title (item 1 of parts)
|
|
194
|
+
end tap
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Where a notice may point, given whether an earlier one is still sitting
|
|
199
|
+
* unclicked in Notification Centre.
|
|
200
|
+
*
|
|
201
|
+
* A click reaches the app with no arguments — macOS just opens whatever posted
|
|
202
|
+
* the notification — so one app can hold exactly one click target, and two
|
|
203
|
+
* outstanding notifications cannot be told apart. Pointing both at the newest
|
|
204
|
+
* review would open the wrong PR from the older notification, silently and
|
|
205
|
+
* convincingly. So the second one drops its fragment and both land on the
|
|
206
|
+
* queue, where every arrival is a row: less specific, never wrong.
|
|
207
|
+
*
|
|
208
|
+
* It heals itself. The target is consumed by the click that follows it, so the
|
|
209
|
+
* next notice after any click deep-links again; only a run of notifications
|
|
210
|
+
* nobody touches stays on the queue.
|
|
211
|
+
*/
|
|
212
|
+
export function clickTarget(url, outstanding) {
|
|
213
|
+
if (!url || !outstanding)
|
|
214
|
+
return url;
|
|
215
|
+
const fragment = url.indexOf("#");
|
|
216
|
+
return fragment < 0 ? url : url.slice(0, fragment);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The notice, as the app reads it back: title, body and click target, one line
|
|
220
|
+
* each. Flattened first — a newline in a PR title would otherwise shift every
|
|
221
|
+
* line after it, and the body would arrive as the URL.
|
|
222
|
+
*/
|
|
223
|
+
export function pendingPayload(n) {
|
|
224
|
+
return `${flatten(n.title)}\n${flatten(n.body)}\n${flatten(n.url ?? "")}\n`;
|
|
225
|
+
}
|
|
226
|
+
async function run(file, args) {
|
|
227
|
+
await execFileAsync(file, args, { timeout: NOTIFY_TIMEOUT_MS });
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Set a key in the applet's Info.plist whether or not it is already there.
|
|
231
|
+
*
|
|
232
|
+
* `plutil -replace` is documented as overwriting an *existing* value — it does
|
|
233
|
+
* create a missing key in practice, but on undocumented behaviour, and every
|
|
234
|
+
* key this sets is one `osacompile` may or may not have written. A failure
|
|
235
|
+
* here fails the build and silently costs the click, so the documented
|
|
236
|
+
* inserting form is the fallback rather than the assumption.
|
|
237
|
+
*/
|
|
238
|
+
async function setPlistValue(plist, key, type, value) {
|
|
239
|
+
const flags = [key, type, value, plist];
|
|
240
|
+
await run("plutil", ["-replace", ...flags]).catch(() => run("plutil", ["-insert", ...flags]));
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Build the app cerber posts through, into `~/.cerber/Cerber.app`.
|
|
244
|
+
*
|
|
245
|
+
* Three things here are load-bearing, each learned the way macOS teaches them —
|
|
246
|
+
* by dropping the notification and saying nothing:
|
|
247
|
+
*
|
|
248
|
+
* - **A bundle identifier.** `osacompile` leaves the applet without one, and
|
|
249
|
+
* a bundle with no identity gets its notifications denied.
|
|
250
|
+
* - **A signature that matches.** Editing `Info.plist` invalidates the ad-hoc
|
|
251
|
+
* signature `osacompile` writes, and a bundle whose seal is broken is denied
|
|
252
|
+
* just as flatly. So it is re-signed, ad-hoc, after the edits.
|
|
253
|
+
* - **A path LaunchServices will register.** A bundle under `/tmp` is never
|
|
254
|
+
* resolved to an app, so the request for permission is never even asked.
|
|
255
|
+
* `~/.cerber` is fine, and `lsregister` makes sure it is known before the
|
|
256
|
+
* first notification rather than after it.
|
|
257
|
+
*/
|
|
258
|
+
async function buildNotifierApp(app, dir) {
|
|
259
|
+
// 0700: the notices left here hold the cockpit's URL, token and all.
|
|
260
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
261
|
+
const source = path.join(dir, "applet.applescript");
|
|
262
|
+
await fs.writeFile(source, appletSource(dir));
|
|
263
|
+
// Staged in the one place LaunchServices refuses to register — the same
|
|
264
|
+
// refusal that makes a bundle under /tmp undeliverable also makes it
|
|
265
|
+
// unlaunchable, which is what a half-built app must be. Staged beside the
|
|
266
|
+
// real one it is a second app with cerber's own bundle identifier, and a
|
|
267
|
+
// launch that reaches it mid-build finds an applet with no script yet and
|
|
268
|
+
// puts up AppleScript's "Press Run to run this script" dialog.
|
|
269
|
+
//
|
|
270
|
+
// The name still ends in .app because osacompile picks what it writes from
|
|
271
|
+
// the extension, and anything else gets a bare script file with no bundle
|
|
272
|
+
// around it — no Info.plist, nothing to sign, nothing to post.
|
|
273
|
+
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "cerber-notifier-"));
|
|
274
|
+
try {
|
|
275
|
+
await assemble(path.join(stagingDir, "Cerber.app"), app, source);
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
// Every attempt, not just the ones that worked: a machine with no
|
|
279
|
+
// osacompile fails here once per process, and each failure would otherwise
|
|
280
|
+
// leave its half-built bundle behind for good.
|
|
281
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Everything between an empty staging directory and an installed, signed app. */
|
|
285
|
+
async function assemble(staged, app, source) {
|
|
286
|
+
await run("osacompile", ["-o", staged, source]);
|
|
287
|
+
const plist = path.join(staged, "Contents", "Info.plist");
|
|
288
|
+
await setPlistValue(plist, "CFBundleIdentifier", "-string", BUNDLE_ID);
|
|
289
|
+
// An agent, not an app: cerber's notifier has no window and no business in
|
|
290
|
+
// the Dock or the app switcher for the second it spends posting.
|
|
291
|
+
await setPlistValue(plist, "LSUIElement", "-bool", "true");
|
|
292
|
+
// The applet ships an asset catalogue holding the generic script icon, and a
|
|
293
|
+
// catalogue wins over anything in Resources/ — so the paw only lands once the
|
|
294
|
+
// catalogue and the name it answers to are both gone.
|
|
295
|
+
const resources = path.join(staged, "Contents", "Resources");
|
|
296
|
+
await fs.rm(path.join(resources, "Assets.car"), { force: true });
|
|
297
|
+
await run("plutil", ["-remove", "CFBundleIconName", plist]).catch(() => { });
|
|
298
|
+
await fs.copyFile(ICON, path.join(resources, "applet.icns")).catch(() => {
|
|
299
|
+
// No icon is a generic icon, not a failure — the tap still works.
|
|
300
|
+
});
|
|
301
|
+
await run("codesign", ["--force", "--sign", "-", staged]);
|
|
302
|
+
// Not atomic, because a directory swap can't be: the window between these two
|
|
303
|
+
// is a few milliseconds in which a notification would fall back to osascript.
|
|
304
|
+
await fs.rm(app, { recursive: true, force: true });
|
|
305
|
+
try {
|
|
306
|
+
await fs.rename(staged, app);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// A CERBER_HOME on another volume: rename can't cross one, and a copy
|
|
310
|
+
// carries the signature with it — it lives in the bundle's own files.
|
|
311
|
+
await fs.cp(staged, app, { recursive: true });
|
|
312
|
+
}
|
|
313
|
+
await run(LSREGISTER, ["-f", app]).catch(() => {
|
|
314
|
+
// Best effort: `open` registers the bundle too, just later than we'd like.
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* One build attempt per home per process, whatever the answer.
|
|
319
|
+
*
|
|
320
|
+
* The poll awaits `announce`, so retrying a hopeless build every time a PR
|
|
321
|
+
* lands would spend a timeout apiece on a machine that is never going to have
|
|
322
|
+
* `osacompile`. A remembered failure falls back to the plain tap instead.
|
|
323
|
+
*/
|
|
324
|
+
const builds = new Map();
|
|
325
|
+
export function ensureNotifierApp() {
|
|
326
|
+
// Resolved, because the paths go *into* the app: CERBER_HOME may be
|
|
327
|
+
// relative, and an app LaunchServices launches on a click inherits none of
|
|
328
|
+
// the daemon's working directory to resolve it against.
|
|
329
|
+
const home = path.resolve(cerberHome());
|
|
330
|
+
let building = builds.get(home);
|
|
331
|
+
if (!building) {
|
|
332
|
+
building = installNotifierApp(home).catch(() => null);
|
|
333
|
+
builds.set(home, building);
|
|
334
|
+
}
|
|
335
|
+
return building;
|
|
336
|
+
}
|
|
337
|
+
async function installNotifierApp(home) {
|
|
338
|
+
const app = path.join(home, "Cerber.app");
|
|
339
|
+
const dir = path.join(home, "notify");
|
|
340
|
+
const stamp = path.join(dir, "build");
|
|
341
|
+
const want = `${APP_BUILD} ${app}\n`;
|
|
342
|
+
const built = await fs
|
|
343
|
+
.stat(path.join(app, "Contents", "Info.plist"))
|
|
344
|
+
.then(() => true)
|
|
345
|
+
.catch(() => false);
|
|
346
|
+
if (built && (await fs.readFile(stamp, "utf8").catch(() => null)) === want)
|
|
347
|
+
return app;
|
|
348
|
+
await buildNotifierApp(app, dir);
|
|
349
|
+
await fs.writeFile(stamp, want);
|
|
350
|
+
return app;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Hand the notice to cerber's own app, so a click on it opens the review.
|
|
354
|
+
* False means the app couldn't take it and the caller should fall back — the
|
|
355
|
+
* tap matters more than which app posts it.
|
|
356
|
+
*/
|
|
357
|
+
async function postThroughApp(n) {
|
|
358
|
+
const app = await ensureNotifierApp();
|
|
359
|
+
if (!app)
|
|
360
|
+
return false;
|
|
361
|
+
// Beside the app that was actually built, rather than wherever CERBER_HOME
|
|
362
|
+
// points now — the paths the applet reads were baked into it at build time.
|
|
363
|
+
const dir = path.join(path.dirname(app), "notify");
|
|
364
|
+
const file = path.join(dir, "pending.txt");
|
|
365
|
+
const tmp = path.join(dir, `pending.${process.pid}.tmp`);
|
|
366
|
+
try {
|
|
367
|
+
// A target the app hasn't consumed means the notification it belongs to is
|
|
368
|
+
// still there to be clicked, and two of them cannot be told apart.
|
|
369
|
+
const outstanding = await fs
|
|
370
|
+
.stat(path.join(dir, "url.txt"))
|
|
371
|
+
.then(() => true)
|
|
372
|
+
.catch(() => false);
|
|
373
|
+
// tmp+rename, so the app can never read half a notice: it reads the file
|
|
374
|
+
// the instant `open` wakes it, and a partial one would post a PR title cut
|
|
375
|
+
// in two or a URL that leads nowhere.
|
|
376
|
+
// 0600 for the same reason the app's own write is: this line is the
|
|
377
|
+
// cockpit URL, and the cockpit URL carries the token that gets in.
|
|
378
|
+
await fs.writeFile(tmp, pendingPayload({ ...n, url: clickTarget(n.url, outstanding) }), {
|
|
379
|
+
mode: 0o600,
|
|
380
|
+
});
|
|
381
|
+
await fs.rename(tmp, file);
|
|
382
|
+
// -g, so a PR landing doesn't pull focus out of whatever you're doing.
|
|
383
|
+
await run("open", ["-g", app]);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
86
391
|
/**
|
|
87
392
|
* Show it, and say whether it was shown. False is an ordinary answer here —
|
|
88
393
|
* an unsupported platform, no `notify-send` installed, a headless box, a
|
|
@@ -91,8 +396,10 @@ export function notifyCommand(n, platform = process.platform) {
|
|
|
91
396
|
* important thing a poll does, so it is also the last thing allowed to stop
|
|
92
397
|
* one.
|
|
93
398
|
*/
|
|
94
|
-
export async function notify(n) {
|
|
95
|
-
|
|
399
|
+
export async function notify(n, platform = process.platform) {
|
|
400
|
+
if (platform === "darwin" && (await postThroughApp(n)))
|
|
401
|
+
return true;
|
|
402
|
+
const cmd = notifyCommand(n, platform);
|
|
96
403
|
if (!cmd)
|
|
97
404
|
return false;
|
|
98
405
|
try {
|
package/dist/core/revise.js
CHANGED
|
@@ -60,6 +60,9 @@ export function mergeConcurrentEdits(before, after, current) {
|
|
|
60
60
|
// there only when it actually revised one.
|
|
61
61
|
status: current.status,
|
|
62
62
|
verdict: after.verdict === before.verdict ? current.verdict : after.verdict,
|
|
63
|
+
// A turn never writes a send body, so one written while it ran is the
|
|
64
|
+
// user's and stands — as does clearing one.
|
|
65
|
+
bodyOverride: current.bodyOverride,
|
|
63
66
|
};
|
|
64
67
|
}
|
|
65
68
|
export function applyRevisions(artifact, revisions, opts = {}) {
|
package/dist/core/send.js
CHANGED
|
@@ -49,7 +49,16 @@ export function buildReviewPayload(artifact, event) {
|
|
|
49
49
|
parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
|
|
50
50
|
return {
|
|
51
51
|
event,
|
|
52
|
-
body
|
|
52
|
+
// A body the user wrote by hand replaces the composed one outright,
|
|
53
|
+
// footer and folded notes included: it is the review's own comment, and
|
|
54
|
+
// half-honouring it — keeping a footer they deleted, re-appending notes
|
|
55
|
+
// they cut — would post something nobody wrote. It is not trimmed either,
|
|
56
|
+
// which is not fussiness: a body opening on an indented line is a markdown
|
|
57
|
+
// code block, and trimming it would silently repaint it as a paragraph.
|
|
58
|
+
// Only the composed body is trimmed — the parts above put a blank line in
|
|
59
|
+
// front of it. The composition still runs regardless, because `folded` is
|
|
60
|
+
// what the cockpit uses to say which comments have no line to land on.
|
|
61
|
+
body: artifact.bodyOverride ?? parts.join("\n").trim(),
|
|
53
62
|
comments: inline,
|
|
54
63
|
folded,
|
|
55
64
|
commitId: artifact.pr.headSha || undefined,
|
package/dist/runner/review.js
CHANGED
|
@@ -168,6 +168,9 @@ async function performReview(ref, pr, existing, opts, log) {
|
|
|
168
168
|
chapters: [],
|
|
169
169
|
comments: [],
|
|
170
170
|
verdict: null,
|
|
171
|
+
// Not carried over. A hand-written send body described the draft this run
|
|
172
|
+
// is replacing — the same reason the pre-chat snapshot goes.
|
|
173
|
+
bodyOverride: null,
|
|
171
174
|
run: {
|
|
172
175
|
model: opts.model ?? null,
|
|
173
176
|
startedAt: now(),
|
package/dist/server/daemon.js
CHANGED
|
@@ -42,6 +42,7 @@ export function stubArtifact(ref) {
|
|
|
42
42
|
chapters: [],
|
|
43
43
|
comments: [],
|
|
44
44
|
verdict: null,
|
|
45
|
+
bodyOverride: null,
|
|
45
46
|
run: null,
|
|
46
47
|
sent: null,
|
|
47
48
|
refresh: null,
|
|
@@ -537,6 +538,8 @@ export function startDaemon(opts) {
|
|
|
537
538
|
}
|
|
538
539
|
/** Said once, not every poll: a machine with no notifier will never grow one. */
|
|
539
540
|
let notifierMissing = false;
|
|
541
|
+
/** Null until `serve` is listening and says so — see `cockpitAt`. */
|
|
542
|
+
let cockpitUrl = opts.cockpitUrl ?? null;
|
|
540
543
|
/**
|
|
541
544
|
* What the last attempt to tap this machine actually did, or null before one
|
|
542
545
|
* has been made.
|
|
@@ -559,7 +562,7 @@ export function startDaemon(opts) {
|
|
|
559
562
|
* why it stands down when this is on and both are on one machine.
|
|
560
563
|
*/
|
|
561
564
|
async function announce(arrivals) {
|
|
562
|
-
const n = notice(arrivals);
|
|
565
|
+
const n = notice(arrivals, cockpitUrl);
|
|
563
566
|
if (!n)
|
|
564
567
|
return;
|
|
565
568
|
notifierWorks = await notify(n);
|
|
@@ -755,6 +758,9 @@ export function startDaemon(opts) {
|
|
|
755
758
|
void poll();
|
|
756
759
|
return {
|
|
757
760
|
status: () => ({ ...status }),
|
|
761
|
+
cockpitAt: (url) => {
|
|
762
|
+
cockpitUrl = url;
|
|
763
|
+
},
|
|
758
764
|
stop: () => {
|
|
759
765
|
stopped = true;
|
|
760
766
|
if (timer)
|
package/dist/server/index.js
CHANGED
|
@@ -261,6 +261,7 @@ export async function buildApp(opts) {
|
|
|
261
261
|
chapters: [],
|
|
262
262
|
comments: [],
|
|
263
263
|
verdict: null,
|
|
264
|
+
bodyOverride: null,
|
|
264
265
|
run: {
|
|
265
266
|
model: null,
|
|
266
267
|
startedAt: now,
|
|
@@ -314,6 +315,13 @@ export async function buildApp(opts) {
|
|
|
314
315
|
if (body.status !== undefined && !SETTLEABLE.includes(body.status)) {
|
|
315
316
|
return c.json({ error: `status must be one of ${SETTLEABLE.join(", ")} — got ${body.status}` }, 400);
|
|
316
317
|
}
|
|
318
|
+
// Coercing this one would be worse than refusing it: `String({})` is
|
|
319
|
+
// "[object Object]", and this field is posted to GitHub verbatim.
|
|
320
|
+
if (body.bodyOverride !== undefined &&
|
|
321
|
+
body.bodyOverride !== null &&
|
|
322
|
+
typeof body.bodyOverride !== "string") {
|
|
323
|
+
return c.json({ error: "bodyOverride must be a string, or null to compose it" }, 400);
|
|
324
|
+
}
|
|
317
325
|
const updated = await updateArtifactByKey(c.req.param("key"), (a) => {
|
|
318
326
|
const next = { ...a };
|
|
319
327
|
if (body.status !== undefined) {
|
|
@@ -329,6 +337,12 @@ export async function buildApp(opts) {
|
|
|
329
337
|
// the moment you click. Same rule the poll's reopen follows.
|
|
330
338
|
next.filed = null;
|
|
331
339
|
}
|
|
340
|
+
// The review body as the user rewrote it — or `null` to hand the body
|
|
341
|
+
// back to the composition it came from. Only ever set from the send
|
|
342
|
+
// panel, where the text being replaced is on screen.
|
|
343
|
+
if (body.bodyOverride !== undefined) {
|
|
344
|
+
next.bodyOverride = body.bodyOverride;
|
|
345
|
+
}
|
|
332
346
|
if (body.verdictRecommendation !== undefined && next.verdict) {
|
|
333
347
|
next.verdict = {
|
|
334
348
|
...next.verdict,
|
|
@@ -676,6 +690,39 @@ export async function buildApp(opts) {
|
|
|
676
690
|
}
|
|
677
691
|
return app;
|
|
678
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* The wildcard binds, and the loopback address each one answers on.
|
|
695
|
+
*
|
|
696
|
+
* Only these two are translated. A bind to `::1`, `localhost` or a particular
|
|
697
|
+
* interface is already an address that answers here, and rewriting it would
|
|
698
|
+
* point the click somewhere nothing is listening — `serve --host ::1` serves
|
|
699
|
+
* IPv6 loopback alone, where `127.0.0.1` does not connect at all. `::` maps to
|
|
700
|
+
* IPv6 loopback rather than IPv4, because a dual-stack socket is the only
|
|
701
|
+
* reason `127.0.0.1` would work on one and it is not guaranteed to be one.
|
|
702
|
+
*/
|
|
703
|
+
const WILDCARD = new Map([
|
|
704
|
+
["0.0.0.0", "127.0.0.1"],
|
|
705
|
+
["::", "::1"],
|
|
706
|
+
]);
|
|
707
|
+
/**
|
|
708
|
+
* The cockpit's own address, as reached from the machine `serve` runs on — what
|
|
709
|
+
* a desktop notification opens when you click it.
|
|
710
|
+
*
|
|
711
|
+
* It is the bound host only when that host is a particular interface; a
|
|
712
|
+
* wildcard bind is every interface, and the one that always answers from here
|
|
713
|
+
* is loopback. The token rides along because a click has to land on the review
|
|
714
|
+
* rather than on a 401, and it is the same token `serve` already prints to the
|
|
715
|
+
* console on startup. Null where there is no address to name yet — port 0 is
|
|
716
|
+
* whatever the OS picks, which isn't known until it has picked it.
|
|
717
|
+
*/
|
|
718
|
+
export function cockpitUrl(opts) {
|
|
719
|
+
if (!Number.isInteger(opts.port) || opts.port <= 0)
|
|
720
|
+
return null;
|
|
721
|
+
const host = WILDCARD.get(opts.host) ?? opts.host;
|
|
722
|
+
const authority = host.includes(":") ? `[${host}]` : host;
|
|
723
|
+
const query = opts.token ? `?token=${encodeURIComponent(opts.token)}` : "";
|
|
724
|
+
return `http://${authority}:${opts.port}/${query}`;
|
|
725
|
+
}
|
|
679
726
|
/**
|
|
680
727
|
* Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
|
|
681
728
|
* caller must have done it before starting the daemon, because the daemon polls
|
|
@@ -687,6 +734,9 @@ export async function startServer(opts) {
|
|
|
687
734
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
688
735
|
const tokenHint = opts.token ? `/?token=${opts.token}` : "";
|
|
689
736
|
console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
|
|
737
|
+
// The bound port, not the asked-for one: `--port 0` is the OS's to choose,
|
|
738
|
+
// and this callback is the first moment anyone knows what it chose.
|
|
739
|
+
opts.daemon?.cockpitAt(cockpitUrl({ ...opts, port: info.port }));
|
|
690
740
|
if (opts.daemon) {
|
|
691
741
|
const s = opts.daemon.status();
|
|
692
742
|
console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Full Stack House (https://fullstack.house)",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
18
|
"web/dist",
|
|
19
|
+
"assets",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|