@fullstackhouse/cerber 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,17 +88,30 @@ who did is the one mistake worth designing against.
88
88
 
89
89
  ### It taps you on the shoulder
90
90
 
91
- When a PR lands in the queue you get a desktop notification naming it, folded
92
- into one popup when several arrive at once. The tap comes from the poll that
93
- found the PR, so it reaches you with the cockpit closed, in another Space, or
94
- after a browser restart — `cerber serve` is the only thing that has to be
95
- running. It goes to whatever this machine already has (Notification Centre on
96
- macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
97
- `daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
98
-
99
- The cockpit has a bell of its own, and it is the better one where it can ring:
100
- its notification **opens that review when you click it**. But it is a page
101
- notification — it needs a tab that is open, alive and permitted, which is
91
+ When a PR is ready for you, you get a desktop notification naming it, folded
92
+ into one popup when several land at once. Ready means *there is something to
93
+ read*: cerber drafts the review itself, so the tap waits for the draft and says
94
+ what it found — "widgets#7 draft ready · requests changes · 2 blockers" — rather
95
+ than walking you back to a row that says "no run yet". A PR nobody is going to
96
+ draft (auto-review off, or a run that failed) is announced as it arrives, since
97
+ that is all the news there will be — and if a retry drafts it after all, that is
98
+ new news and you are told. Nothing else is: a PR whose draft you have been told
99
+ about stays quiet however its next re-review ends. The tap comes from the poll, so it reaches
100
+ you with the cockpit closed, in another Space, or after a browser restart —
101
+ `cerber serve` is the only thing that has to be running. It goes to whatever
102
+ this machine already has (Notification Centre on macOS, `notify-send` on Linux)
103
+ and stays quiet where there is neither. Off with `daemon.notify` in
104
+ `~/.cerber/config.json`, or the checkbox in Settings.
105
+
106
+ **On macOS, clicking it opens that review** in the cockpit. It takes a small app
107
+ cerber builds once under `~/.cerber` — the first tap asks you to allow
108
+ notifications from "Cerber" — because a notification can only open the app that
109
+ posted it, and one posted the usual way belongs to Script Editor, which is
110
+ where the click would otherwise land. A `notify-send` tap on Linux announces
111
+ the PR but has no click to give.
112
+
113
+ The cockpit has a bell of its own, which does the same from the page. But it is
114
+ a page notification — it needs a tab that is open, alive and permitted, which is
102
115
  exactly what you don't have on the afternoons this feature is for. So it stands
103
116
  down while the machine's own tap is on, rather than making one PR two popups,
104
117
  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")
@@ -358,7 +358,7 @@ program
358
358
  .option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
359
359
  .option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
360
360
  .option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
361
- .option("--no-notify", "don't notify this machine when a PR lands in the queue")
361
+ .option("--no-notify", "don't notify this machine when a review is ready for you")
362
362
  .option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
363
363
  .option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
364
364
  .option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
@@ -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
  })
@@ -304,6 +304,32 @@ export const ArtifactSchema = z.object({
304
304
  * field forward long after the decision it is meant to date.
305
305
  */
306
306
  settledAt: z.string().nullable().default(null),
307
+ /**
308
+ * The announcement ledger: what this machine has already told you about this
309
+ * row — see `pendingNews` in `src/core/notify.ts` for what is worth telling.
310
+ *
311
+ * Three states, not two. `null` is "the poll found this and owes you a tap";
312
+ * a record is "already told". **Absent** is neither: nobody ever meant to
313
+ * announce this row — a review you pulled in by hand, or one written before
314
+ * the ledger existed — so it is never news, and an upgrade doesn't announce
315
+ * a queue the user has been looking at for weeks. Only `stubArtifact` writes
316
+ * the null.
317
+ *
318
+ * It records *what* was said, not just that something was, because the two
319
+ * things cerber can say about a row are different news: "nobody is drafting
320
+ * this" and "here is the draft". A run that fails is announced as the first,
321
+ * is retried on the next poll, and may then succeed — and a ledger that only
322
+ * remembered "told" would swallow the draft-ready tap, leaving the user with
323
+ * the one notification that had nothing to read behind it.
324
+ */
325
+ notified: z
326
+ .object({
327
+ at: z.string(),
328
+ /** True when what was announced was a finished draft. */
329
+ drafted: z.boolean(),
330
+ })
331
+ .nullable()
332
+ .optional(),
307
333
  /** Last time this review was pulled forward onto a newer head commit. */
308
334
  refresh: RefreshInfoSchema.nullable().default(null),
309
335
  calibration: CalibrationSchema.nullable().default(null),
@@ -23,9 +23,10 @@ export const DaemonConfigSchema = z.object({
23
23
  /** Draft an AI review for whatever lands in the queue. Never sends. */
24
24
  autoReview: z.boolean().default(true),
25
25
  /**
26
- * Tap this machine's notification centre when a PR lands in the queue. The
27
- * cockpit's bell needs a tab open and permitted; this one only needs `serve`
28
- * running, which is the point of it.
26
+ * Tap this machine's notification centre when a PR is worth coming back for
27
+ * — the draft landing, or the arrival itself when nothing is going to be
28
+ * drafted (`isNews` in `core/notify.ts`). The cockpit's bell needs a tab open
29
+ * and permitted; this one only needs `serve` running, which is the point.
29
30
  */
30
31
  notify: z.boolean().default(true),
31
32
  intervalMinutes: z.number().int().positive().default(5),
@@ -4,14 +4,37 @@
4
4
  // one when it can ring: it names the PR and opens that review when you click
5
5
  // it. But it is a page notification, so it needs a tab that is open, alive and
6
6
  // permitted — which is exactly what you don't have while you're in an editor
7
- // all afternoon. This half rides the poll that discovers the PR instead, so the
8
- // tap survives a closed cockpit, a denied permission and a browser restart.
7
+ // all afternoon. This half rides the poll instead, so the tap survives a closed
8
+ // cockpit, a denied permission and a browser restart.
9
+ //
10
+ // It taps you once per piece of news, at the moment that news is worth walking
11
+ // back for. With auto-review on — the default — that is when the draft is
12
+ // written, not when the PR lands: a tap that leads to "no run yet, press r" is
13
+ // a tap that costs you the walk back and gives you nothing. `isNews` decides
14
+ // whether there is news at all, `pendingNews` whether it is news you have
15
+ // already been told. A PR has at most two: "nobody is drafting this", and
16
+ // "here is the draft" — and it only gets the second if it got the first.
17
+ //
18
+ // A click has to land in the same place either way, which on macOS is the whole
19
+ // difference between the two paths below. `osascript -e 'display notification'`
20
+ // posts as Script Editor — that is the app macOS sees making the call — so
21
+ // clicking the tap opens Script Editor, an app the user never asked for and a
22
+ // dead end from a notification about a PR. A notification can only open what
23
+ // posted it, so cerber posts its own: a small app bundle built once under
24
+ // `~/.cerber`, which opens the review in the cockpit when clicked. Where it
25
+ // can't be built, the plain `osascript` tap is still better than silence.
9
26
  //
10
27
  // Everything here shells out to whatever the OS already has. No dependency, no
11
28
  // daemon of its own, and a machine with no notifier is a machine cerber stays
12
29
  // quiet on rather than one it fails on.
13
30
  import { execFile } from "node:child_process";
31
+ import { promises as fs } from "node:fs";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import { fileURLToPath } from "node:url";
14
35
  import { promisify } from "node:util";
36
+ import { artifactId, artifactKey } from "./artifact.js";
37
+ import { cerberHome } from "./state.js";
15
38
  const execFileAsync = promisify(execFile);
16
39
  /**
17
40
  * How long a notifier gets before the poll stops waiting for it.
@@ -25,23 +48,123 @@ const execFileAsync = promisify(execFile);
25
48
  */
26
49
  export const NOTIFY_TIMEOUT_MS = 5_000;
27
50
  const slug = (a) => `${a.repo}#${a.number}`;
51
+ const VERDICT_WORDS = {
52
+ approve: "approves",
53
+ comment: "comments",
54
+ request_changes: "requests changes",
55
+ };
56
+ /**
57
+ * What a finished draft says, in the few words a popup gets: the verdict, and
58
+ * the count the verdict rests on. A draft that graded nothing and left no
59
+ * verdict still gets a line — "drafted" is the news there.
60
+ */
61
+ function draftLine(d) {
62
+ const verdict = d.recommendation ? VERDICT_WORDS[d.recommendation] : "drafted";
63
+ if (d.blockers === 0)
64
+ return verdict;
65
+ return `${verdict} · ${d.blockers} blocker${d.blockers === 1 ? "" : "s"}`;
66
+ }
28
67
  /**
29
- * One popup for one poll's arrivals — a batch is one interruption, not five.
68
+ * Text a notification body or an AppleScript literal can actually hold. A PR
69
+ * title is somebody else's text and can carry anything; control characters
70
+ * collapse to a space rather than splitting a line the applet reads back by
71
+ * line, or ending a string literal early.
72
+ */
73
+ const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
74
+ /**
75
+ * One popup for one poll's news — a batch is one interruption, not five.
30
76
  * Deliberately the same shape and wording the cockpit's bell uses, so the two
31
77
  * channels never read as two different pieces of news about one PR.
78
+ *
79
+ * `cockpit` is where clicking it lands: the review itself when the notice is
80
+ * about exactly one PR, the queue when it is about several. It must be an
81
+ * origin with no fragment of its own — the deep link is a fragment, and the
82
+ * cockpit is a hash-routed page.
32
83
  */
33
- export function notice(arrived) {
34
- if (arrived.length === 0)
84
+ export function notice(news, cockpit) {
85
+ if (news.length === 0)
35
86
  return null;
36
- if (arrived.length === 1) {
37
- const a = arrived[0];
38
- return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}` };
87
+ const base = cockpit || null;
88
+ if (news.length === 1) {
89
+ const a = news[0];
90
+ const key = artifactKey(artifactId(a));
91
+ const url = base && `${base}#/r/${encodeURIComponent(key)}`;
92
+ // The draft is the news when there is one, so the popup leads with what it
93
+ // found rather than with the PR having arrived — which, by then, it did
94
+ // minutes ago.
95
+ if (a.draft)
96
+ return { title: `${slug(a)} draft ready`, body: `${draftLine(a.draft)} — ${a.title}`, url };
97
+ return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}`, url };
39
98
  }
40
- const named = arrived.slice(0, 3).map(slug);
41
- const rest = arrived.length - named.length;
99
+ const named = news.slice(0, 3).map(slug);
100
+ const rest = news.length - named.length;
42
101
  return {
43
- title: `${arrived.length} PRs await your review`,
102
+ // A drafted PR awaits you too, so the general wording is true of any batch;
103
+ // the better headline is only claimed when every one of them is drafted.
104
+ title: news.every((n) => n.draft) ? `${news.length} drafts ready` : `${news.length} PRs await your review`,
44
105
  body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
106
+ url: base,
107
+ };
108
+ }
109
+ /**
110
+ * Whether this row is news *yet*.
111
+ *
112
+ * The one rule behind the tap's timing, and the cockpit's bell mirrors it
113
+ * (`web/src/notify.ts`). A row cerber is about to draft, or is drafting, is not
114
+ * news: the tap would arrive minutes before there is anything to read, and the
115
+ * one after it would never come. So it waits — unless nothing is coming, which
116
+ * is the case when auto-review is off (nobody will draft this until you say so)
117
+ * or the run failed (nobody will draft it at all).
118
+ *
119
+ * A row you have settled is not news either, whatever else happened to it: you
120
+ * already answered it.
121
+ */
122
+ export function isNews(a, autoReview) {
123
+ if (a.pr.state !== "OPEN")
124
+ return false;
125
+ if (a.status === "running")
126
+ return false;
127
+ if (a.status === "awaiting")
128
+ return !autoReview;
129
+ return a.status === "ready" || a.status === "failed";
130
+ }
131
+ /**
132
+ * What this row has to say that it has not said already, or null for silence.
133
+ *
134
+ * `isNews` answers "is there news at all"; this one also weighs it against the
135
+ * ledger, which is where the two things cerber can say about a row stop being
136
+ * interchangeable. A run that failed is announced as "nobody is drafting this",
137
+ * is retried on the next poll, and may then succeed — so the draft is news
138
+ * *again*, to someone who was told the opposite five minutes ago. The reverse
139
+ * is not: a row that had a draft and now has a broken re-review is a PR the
140
+ * user already knows about, and a second tap there is noise.
141
+ */
142
+ export function pendingNews(a, autoReview) {
143
+ // Absent, not null: nobody ever meant to announce this row.
144
+ if (a.notified === undefined)
145
+ return null;
146
+ if (!isNews(a, autoReview))
147
+ return null;
148
+ const news = newsOf(a);
149
+ if (a.notified === null)
150
+ return news;
151
+ return news.draft && !a.notified.drafted ? news : null;
152
+ }
153
+ /** What that row would say, for the one notice a poll sends. */
154
+ export function newsOf(a) {
155
+ return {
156
+ // The click target is built from the ref, so the owner travels with it.
157
+ owner: a.pr.owner,
158
+ repo: a.pr.repo,
159
+ number: a.pr.number,
160
+ title: a.pr.title,
161
+ author: a.pr.author,
162
+ draft: a.status === "ready"
163
+ ? {
164
+ recommendation: a.verdict?.recommendation ?? null,
165
+ blockers: a.comments.filter((c) => c.status !== "dropped" && c.severity === "blocker").length,
166
+ }
167
+ : null,
45
168
  };
46
169
  }
47
170
  /**
@@ -54,13 +177,15 @@ export function notice(arrived) {
54
177
  * can't appear in a literal at all, so they collapse to a space.
55
178
  */
56
179
  export function appleScriptLiteral(value) {
57
- const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
58
- return `"${flat.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
180
+ return `"${flatten(value).replace(/[\\"]/g, (c) => `\\${c}`)}"`;
59
181
  }
60
182
  /**
61
183
  * The command that taps this machine's notification centre, or null where there
62
184
  * is nothing to tap. macOS and the freedesktop notifiers cover what cerber runs
63
185
  * on; anywhere else it stays quiet rather than pretending.
186
+ *
187
+ * On macOS this is the fallback rather than the first choice — see
188
+ * `ensureNotifierApp` for the one whose click opens the review.
64
189
  */
65
190
  export function notifyCommand(n, platform = process.platform) {
66
191
  if (platform === "darwin") {
@@ -83,6 +208,275 @@ export function notifyCommand(n, platform = process.platform) {
83
208
  }
84
209
  return null;
85
210
  }
211
+ /**
212
+ * What the notification centre knows cerber by. Permission is granted to this
213
+ * identifier, once, by the user — so it has to survive every rebuild, or each
214
+ * new version of cerber would ask for notifications again as a stranger.
215
+ */
216
+ export const BUNDLE_ID = "house.fullstack.cerber";
217
+ /** Bumped whenever the applet or its plist changes, so an installed app is replaced. */
218
+ const APP_BUILD = "3";
219
+ const ICON = fileURLToPath(new URL("../../assets/cerber.icns", import.meta.url));
220
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
221
+ /**
222
+ * The app's whole job, in the only language a bundle this small can be written
223
+ * in without a compiler on the user's machine.
224
+ *
225
+ * It is launched twice per notification and has to tell the two apart with no
226
+ * arguments, because a click gives it none: macOS just opens the app that
227
+ * posted the notification. The pending file is what distinguishes them, and
228
+ * reading it is destructive — a launch that finds a notice posts it, a launch
229
+ * that finds none is the click on the last one, and opens where that one
230
+ * pointed. Nothing else can put the app in either state, so the two can't be
231
+ * confused.
232
+ *
233
+ * The paths are baked in rather than derived from the home folder, so a cerber
234
+ * running under CERBER_HOME talks to the app it actually built.
235
+ */
236
+ export function appletSource(dir) {
237
+ const pending = appleScriptLiteral(path.join(dir, "pending.txt"));
238
+ const landing = appleScriptLiteral(path.join(dir, "url.txt"));
239
+ // `on run` is a launch from cold, `on reopen` a click while it is still up
240
+ // after posting — same job either way.
241
+ return `on run
242
+ tap()
243
+ end run
244
+
245
+ on reopen
246
+ tap()
247
+ end reopen
248
+
249
+ on tap()
250
+ set pendingFile to ${pending}
251
+ set urlFile to ${landing}
252
+ try
253
+ set raw to do shell script "cat " & quoted form of pendingFile & " 2>/dev/null; rm -f " & quoted form of pendingFile
254
+ on error
255
+ set raw to ""
256
+ end try
257
+ if raw is "" then
258
+ try
259
+ set target to do shell script "cat " & quoted form of urlFile & " 2>/dev/null"
260
+ on error
261
+ set target to ""
262
+ end try
263
+ -- Consumed on the way out, the same way the notice was: what is left
264
+ -- behind is what cerber reads as "an earlier notification is still
265
+ -- sitting there unclicked".
266
+ if target is not "" then
267
+ do shell script "open " & quoted form of target & "; rm -f " & quoted form of urlFile
268
+ else
269
+ do shell script "rm -f " & quoted form of urlFile
270
+ end if
271
+ return
272
+ end if
273
+ -- do shell script hands back the lines separated by return, not linefeed.
274
+ set AppleScript's text item delimiters to return
275
+ set parts to text items of raw
276
+ if (count of parts) < 2 then return
277
+ set target to ""
278
+ if (count of parts) > 2 then set target to item 3 of parts
279
+ -- umask, because the target carries the cockpit's token: nothing here is
280
+ -- another account's business to read.
281
+ do shell script "umask 077; printf %s " & quoted form of target & " > " & quoted form of urlFile
282
+ display notification (item 2 of parts) with title (item 1 of parts)
283
+ end tap
284
+ `;
285
+ }
286
+ /**
287
+ * Where a notice may point, given whether an earlier one is still sitting
288
+ * unclicked in Notification Centre.
289
+ *
290
+ * A click reaches the app with no arguments — macOS just opens whatever posted
291
+ * the notification — so one app can hold exactly one click target, and two
292
+ * outstanding notifications cannot be told apart. Pointing both at the newest
293
+ * review would open the wrong PR from the older notification, silently and
294
+ * convincingly. So the second one drops its fragment and both land on the
295
+ * queue, where every arrival is a row: less specific, never wrong.
296
+ *
297
+ * It heals itself. The target is consumed by the click that follows it, so the
298
+ * next notice after any click deep-links again; only a run of notifications
299
+ * nobody touches stays on the queue.
300
+ */
301
+ export function clickTarget(url, outstanding) {
302
+ if (!url || !outstanding)
303
+ return url;
304
+ const fragment = url.indexOf("#");
305
+ return fragment < 0 ? url : url.slice(0, fragment);
306
+ }
307
+ /**
308
+ * The notice, as the app reads it back: title, body and click target, one line
309
+ * each. Flattened first — a newline in a PR title would otherwise shift every
310
+ * line after it, and the body would arrive as the URL.
311
+ */
312
+ export function pendingPayload(n) {
313
+ return `${flatten(n.title)}\n${flatten(n.body)}\n${flatten(n.url ?? "")}\n`;
314
+ }
315
+ async function run(file, args) {
316
+ await execFileAsync(file, args, { timeout: NOTIFY_TIMEOUT_MS });
317
+ }
318
+ /**
319
+ * Set a key in the applet's Info.plist whether or not it is already there.
320
+ *
321
+ * `plutil -replace` is documented as overwriting an *existing* value — it does
322
+ * create a missing key in practice, but on undocumented behaviour, and every
323
+ * key this sets is one `osacompile` may or may not have written. A failure
324
+ * here fails the build and silently costs the click, so the documented
325
+ * inserting form is the fallback rather than the assumption.
326
+ */
327
+ async function setPlistValue(plist, key, type, value) {
328
+ const flags = [key, type, value, plist];
329
+ await run("plutil", ["-replace", ...flags]).catch(() => run("plutil", ["-insert", ...flags]));
330
+ }
331
+ /**
332
+ * Build the app cerber posts through, into `~/.cerber/Cerber.app`.
333
+ *
334
+ * Three things here are load-bearing, each learned the way macOS teaches them —
335
+ * by dropping the notification and saying nothing:
336
+ *
337
+ * - **A bundle identifier.** `osacompile` leaves the applet without one, and
338
+ * a bundle with no identity gets its notifications denied.
339
+ * - **A signature that matches.** Editing `Info.plist` invalidates the ad-hoc
340
+ * signature `osacompile` writes, and a bundle whose seal is broken is denied
341
+ * just as flatly. So it is re-signed, ad-hoc, after the edits.
342
+ * - **A path LaunchServices will register.** A bundle under `/tmp` is never
343
+ * resolved to an app, so the request for permission is never even asked.
344
+ * `~/.cerber` is fine, and `lsregister` makes sure it is known before the
345
+ * first notification rather than after it.
346
+ */
347
+ async function buildNotifierApp(app, dir) {
348
+ // 0700: the notices left here hold the cockpit's URL, token and all.
349
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
350
+ const source = path.join(dir, "applet.applescript");
351
+ await fs.writeFile(source, appletSource(dir));
352
+ // Staged in the one place LaunchServices refuses to register — the same
353
+ // refusal that makes a bundle under /tmp undeliverable also makes it
354
+ // unlaunchable, which is what a half-built app must be. Staged beside the
355
+ // real one it is a second app with cerber's own bundle identifier, and a
356
+ // launch that reaches it mid-build finds an applet with no script yet and
357
+ // puts up AppleScript's "Press Run to run this script" dialog.
358
+ //
359
+ // The name still ends in .app because osacompile picks what it writes from
360
+ // the extension, and anything else gets a bare script file with no bundle
361
+ // around it — no Info.plist, nothing to sign, nothing to post.
362
+ const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "cerber-notifier-"));
363
+ try {
364
+ await assemble(path.join(stagingDir, "Cerber.app"), app, source);
365
+ }
366
+ finally {
367
+ // Every attempt, not just the ones that worked: a machine with no
368
+ // osacompile fails here once per process, and each failure would otherwise
369
+ // leave its half-built bundle behind for good.
370
+ await fs.rm(stagingDir, { recursive: true, force: true });
371
+ }
372
+ }
373
+ /** Everything between an empty staging directory and an installed, signed app. */
374
+ async function assemble(staged, app, source) {
375
+ await run("osacompile", ["-o", staged, source]);
376
+ const plist = path.join(staged, "Contents", "Info.plist");
377
+ await setPlistValue(plist, "CFBundleIdentifier", "-string", BUNDLE_ID);
378
+ // An agent, not an app: cerber's notifier has no window and no business in
379
+ // the Dock or the app switcher for the second it spends posting.
380
+ await setPlistValue(plist, "LSUIElement", "-bool", "true");
381
+ // The applet ships an asset catalogue holding the generic script icon, and a
382
+ // catalogue wins over anything in Resources/ — so the paw only lands once the
383
+ // catalogue and the name it answers to are both gone.
384
+ const resources = path.join(staged, "Contents", "Resources");
385
+ await fs.rm(path.join(resources, "Assets.car"), { force: true });
386
+ await run("plutil", ["-remove", "CFBundleIconName", plist]).catch(() => { });
387
+ await fs.copyFile(ICON, path.join(resources, "applet.icns")).catch(() => {
388
+ // No icon is a generic icon, not a failure — the tap still works.
389
+ });
390
+ await run("codesign", ["--force", "--sign", "-", staged]);
391
+ // Not atomic, because a directory swap can't be: the window between these two
392
+ // is a few milliseconds in which a notification would fall back to osascript.
393
+ await fs.rm(app, { recursive: true, force: true });
394
+ try {
395
+ await fs.rename(staged, app);
396
+ }
397
+ catch {
398
+ // A CERBER_HOME on another volume: rename can't cross one, and a copy
399
+ // carries the signature with it — it lives in the bundle's own files.
400
+ await fs.cp(staged, app, { recursive: true });
401
+ }
402
+ await run(LSREGISTER, ["-f", app]).catch(() => {
403
+ // Best effort: `open` registers the bundle too, just later than we'd like.
404
+ });
405
+ }
406
+ /**
407
+ * One build attempt per home per process, whatever the answer.
408
+ *
409
+ * The poll awaits `announce`, so retrying a hopeless build every time a PR
410
+ * lands would spend a timeout apiece on a machine that is never going to have
411
+ * `osacompile`. A remembered failure falls back to the plain tap instead.
412
+ */
413
+ const builds = new Map();
414
+ export function ensureNotifierApp() {
415
+ // Resolved, because the paths go *into* the app: CERBER_HOME may be
416
+ // relative, and an app LaunchServices launches on a click inherits none of
417
+ // the daemon's working directory to resolve it against.
418
+ const home = path.resolve(cerberHome());
419
+ let building = builds.get(home);
420
+ if (!building) {
421
+ building = installNotifierApp(home).catch(() => null);
422
+ builds.set(home, building);
423
+ }
424
+ return building;
425
+ }
426
+ async function installNotifierApp(home) {
427
+ const app = path.join(home, "Cerber.app");
428
+ const dir = path.join(home, "notify");
429
+ const stamp = path.join(dir, "build");
430
+ const want = `${APP_BUILD} ${app}\n`;
431
+ const built = await fs
432
+ .stat(path.join(app, "Contents", "Info.plist"))
433
+ .then(() => true)
434
+ .catch(() => false);
435
+ if (built && (await fs.readFile(stamp, "utf8").catch(() => null)) === want)
436
+ return app;
437
+ await buildNotifierApp(app, dir);
438
+ await fs.writeFile(stamp, want);
439
+ return app;
440
+ }
441
+ /**
442
+ * Hand the notice to cerber's own app, so a click on it opens the review.
443
+ * False means the app couldn't take it and the caller should fall back — the
444
+ * tap matters more than which app posts it.
445
+ */
446
+ async function postThroughApp(n) {
447
+ const app = await ensureNotifierApp();
448
+ if (!app)
449
+ return false;
450
+ // Beside the app that was actually built, rather than wherever CERBER_HOME
451
+ // points now — the paths the applet reads were baked into it at build time.
452
+ const dir = path.join(path.dirname(app), "notify");
453
+ const file = path.join(dir, "pending.txt");
454
+ const tmp = path.join(dir, `pending.${process.pid}.tmp`);
455
+ try {
456
+ // A target the app hasn't consumed means the notification it belongs to is
457
+ // still there to be clicked, and two of them cannot be told apart.
458
+ const outstanding = await fs
459
+ .stat(path.join(dir, "url.txt"))
460
+ .then(() => true)
461
+ .catch(() => false);
462
+ // tmp+rename, so the app can never read half a notice: it reads the file
463
+ // the instant `open` wakes it, and a partial one would post a PR title cut
464
+ // in two or a URL that leads nowhere.
465
+ // 0600 for the same reason the app's own write is: this line is the
466
+ // cockpit URL, and the cockpit URL carries the token that gets in.
467
+ await fs.writeFile(tmp, pendingPayload({ ...n, url: clickTarget(n.url, outstanding) }), {
468
+ mode: 0o600,
469
+ });
470
+ await fs.rename(tmp, file);
471
+ // -g, so a PR landing doesn't pull focus out of whatever you're doing.
472
+ await run("open", ["-g", app]);
473
+ return true;
474
+ }
475
+ catch {
476
+ await fs.rm(tmp, { force: true }).catch(() => { });
477
+ return false;
478
+ }
479
+ }
86
480
  /**
87
481
  * Show it, and say whether it was shown. False is an ordinary answer here —
88
482
  * an unsupported platform, no `notify-send` installed, a headless box, a
@@ -91,8 +485,10 @@ export function notifyCommand(n, platform = process.platform) {
91
485
  * important thing a poll does, so it is also the last thing allowed to stop
92
486
  * one.
93
487
  */
94
- export async function notify(n) {
95
- const cmd = notifyCommand(n);
488
+ export async function notify(n, platform = process.platform) {
489
+ if (platform === "darwin" && (await postThroughApp(n)))
490
+ return true;
491
+ const cmd = notifyCommand(n, platform);
96
492
  if (!cmd)
97
493
  return false;
98
494
  try {
@@ -76,6 +76,8 @@ export function mergeRunResult(fresh, current) {
76
76
  // the stamp away with the status.
77
77
  settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
78
78
  sent: current.sent,
79
+ // Disk wins: the ledger is the poll's, and the run knows nothing about it.
80
+ notified: current.notified,
79
81
  calibration: current.calibration,
80
82
  filed: current.filed,
81
83
  chat: current.chat,
@@ -63,6 +63,9 @@ export function mergeConcurrentEdits(before, after, current) {
63
63
  // A turn never writes a send body, so one written while it ran is the
64
64
  // user's and stands — as does clearing one.
65
65
  bodyOverride: current.bodyOverride,
66
+ // Nor does a turn announce anything: the ledger is the poll's, and a turn
67
+ // takes minutes, which is long enough for a poll to have stamped it.
68
+ notified: current.notified,
66
69
  };
67
70
  }
68
71
  export function applyRevisions(artifact, revisions, opts = {}) {