SLOPSHOPPER

notary-watch

Follows Apple notarization submissions made with xcrun notarytool submit, directly or from a release script: status line while in progress, toast when accepted…

newguardcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notary-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /notary-watch ⎿ notary-watch: No notarization submission tracked this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

notary-watch

Follows the Apple notarization submissions Claude makes with xcrun notarytool submit, typed directly or run from a release script (./Scripts/release.sh 1.2.0), so a long notarization does not have to be checked by hand.

What it does

  • Watches the main agent's Bash calls that run xcrun notarytool submit …, also behind rtk, env, VAR=value, cd dir && or a pipe. Subagent calls are ignored. A failed call counts only when its output still shows a status (a --wait cut short or ended on Invalid): the upload went through.
  • Reads the submission id from the output: the id key when the command used --output-format json (or -f json), otherwise the UUID after the id: field label. Other UUIDs (an issuer, a path) are never taken for the id.
  • With --wait: takes the final status from the output and shows a toast, with no polling:
  • notary ✓ accepted — now staple (1a2b3c4d)
  • notary ✗ invalid (1a2b3c4d) — see why: xcrun notarytool log <id> <auth> (same for rejected)
  • Without --wait, or when --timeout ended the wait while still in progress:
  • status line notary: 1a2b3c4d In Progress (notary: N in progress for several)
  • every 60 s runs xcrun notarytool info <id> <auth> --output-format json (30 s timeout) with the same auth options as the submit: --keychain-profile/-p (plus --keychain), --apple-id + --team-id + --password, or --key/-k + --key-id/-d (+ --issuer/-i)
  • on Accepted, Invalid or Rejected: the toast above, then polling stops and the line clears
  • a failed or unreadable poll is retried at the next minute; after 2 hours it gives up with a toast
  • The auth options are kept in the module's memory only, for as long as polling lasts: never in $.store, a toast, the status line or /notary-watch (except a release script run's keychain profile name, not a secret, which /notary-watch shows). The log hint prints <auth> as a placeholder.
  • When the auth comes from the shell (--keychain-profile "$NOTARY_PROFILE", a backquote) or would prompt (--apple-id without --password), the submission is listed but not polled, and a toast says so.

Release scripts

  • A main-agent Bash command that runs a script whose file name contains scriptPattern (default release) counts as a release run: ./Scripts/release.sh 1.2.0, cd app && Scripts/release-full.sh, bash release.sh, also behind rtk, env or VAR=value. Only the command position counts: cd release, echo Scripts/release.sh, cat release.sh or gh release create do not.
  • The start time is noted before the command runs. Once its result is back (success or failure; the result is never delayed), the mod runs xcrun notarytool history --keychain-profile <profile> --output-format json (30 s timeout) and takes the submissions whose createdDate is at most 60 s before the start.
  • Each new submission is followed exactly like a direct submit (status line, notarytool info every 60 s, toasts, 2 h limit), with --keychain-profile <profile> as its auth. One that history already shows Accepted, Invalid or Rejected is only toasted. An id already followed (a direct submit in the same command, an earlier scan) is never followed twice.
  • A script run in the background (run_in_background, Ctrl+B, or moved there on timeout) returns at once: history is then asked every 60 s for 30 min.
  • The keychain profile is the keychainProfile option, or, when empty, read from the script's text (comments ignored): the --keychain-profile values, resolved through NOTARY_PROFILE="${NOTARY_PROFILE:-<name>}"-style defaults (a NOTARY_PROFILE=… written before the script on the command line wins), else such a *PROFILE* default alone. When it cannot be told (a value from $1, several different profiles, an unreadable script), nothing is asked and /notary-watch says so. The profile name may appear in /notary-watch, never in a toast.

Configuration

OptionDefaultEffect
scriptPatternreleaseText a release script's file name contains (case-insensitive); empty turns script support off
keychainProfile(empty)The notarytool keychain profile history is asked with; empty reads it from the script

Set them in the config menu, or under pluginConfigs in settings:

{ "pluginConfigs": { "notary-watch": { "options": { "keychainProfile": "MyNotaryProfile" } } } }

Commands

CommandEffect
/notary-watchLists the submissions seen this session (id, file, status, age, polling state), then each release script run (name, age, profile, new submissions found, state)
/notary-watch stopStops all polling, including background history watching, and clears the status line

Example

xcrun notarytool submit release/App.zip --keychain-profile MyNotaryProfile --output-format json
# status line: notary: 1a2b3c4d In Progress
# a few minutes later, toast: notary ✓ accepted — now staple (1a2b3c4d)

./Scripts/release.sh 1.2.0
# after it returns: notarytool history finds the submission it made, then as above

Install

claude --plugin-dir /path/to/ModsTools/mods/notary-watch

Limits

  • A script that submits is seen only through notarytool history with a keychain profile: a script that authenticates with an API key or an Apple ID and password is not followed unless keychainProfile names a profile for the same team. A direct submit run in the background (no output yet) is not seen.
  • history lists the whole team's submissions: one made by a teammate or another machine in the same window is followed too.
  • A script is recognised in command position only, by its file name; the profile is read from that file's text only (nothing it sources, no environment but the command line's own VAR=value). Its path is resolved from the session folder plus any cd in the same command, so a Bash shell left in another folder by an earlier cd may not find it.
  • A foreground script run gets one history check; if it fails (timeout, network), /notary-watch says so and nothing is retried. Background runs are watched for 30 min only.
  • The history JSON shape (history[] of createdDate, id, name, status) is taken from notarytool's --help and its own key names; it was not observed against an account.
  • --output-format plist output is not parsed (JSON and the normal format are).
  • Tracking lives in memory: a session restart or a reload of the mod forgets it.
  • Polling runs in the session folder, so a relative --key path given after a cd may not resolve.
  • Status values are notarytool's own (Accepted, In Progress, Invalid, Rejected); English sentences in its output are never matched.

Develop

claude plugin validate mods/notary-watch
claude plugin test mods/notary-watch   # 29 tests
Source 2 files
hooks/register.ts 275 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { IN_PROGRESS, age, finalToast, isFinal, newSubmissions, parseHistory, parseInfo, parseOutput, parseSubmit, scriptCall, scriptProfile, statusLine, timedOutToast, unpolledToast } from './notary'
4import type { ScriptCall } from './notary'
5
6const POLL_MS = 60_000
7const INFO_MS = 30_000
8const MAX_MS = 2 * 60 * 60_000
9const SCRIPT_MAX_MS = 30 * 60_000
10
11type Tracked = {
12  id: string
13  file: string | null
14  status: string
15  startedAt: number
16  // The auth argv replayed to `notarytool info`: kept in this module's memory only, never stored or shown.
17  auth: string[] | null
18  polling: boolean
19  note: string | null
20}
21
22// One run of a release script: where its submissions are looked for in `notarytool history`.
23type ScriptRun = {
24  name: string
25  startedAt: number
26  background: boolean
27  // The keychain profile `history` is asked with (not a secret: shown in /notary-watch, never in a toast).
28  profile: string | null
29  from: 'keychainProfile' | 'script' | null
30  found: number
31  note: string
32  timer: { cancel: () => void } | null
33  busy: boolean
34}
35
36type Watch = {
37  tracked: Map<string, Tracked>
38  timer: { cancel: () => void } | null
39  busy: boolean
40  scripts: ScriptRun[]
41}
42
43function showStatus($: EngineInterface, watch: Watch) {
44  $.ui.status(statusLine([...watch.tracked.values()].filter(one => one.polling)))
45}
46
47function stopTimerIfIdle(watch: Watch) {
48  if (watch.timer !== null && ![...watch.tracked.values()].some(one => one.polling)) {
49    watch.timer.cancel()
50    watch.timer = null
51  }
52}
53
54async function poll($: EngineInterface, watch: Watch, one: Tracked, now: number) {
55  if (now - one.startedAt >= MAX_MS) {
56    one.polling = false
57    one.note = 'stopped after 2h'
58    $.ui.toast(timedOutToast(one.id))
59    return
60  }
61  let status: string | null = null
62  try {
63    const ran = await $.process.run(['xcrun', 'notarytool', 'info', one.id, ...(one.auth ?? []), '--output-format', 'json'], { timeoutMs: INFO_MS })
64    status = ran.exitCode === 0 ? parseInfo(ran.stdout) : null
65  } catch {
66    status = null
67  }
68  // A failed or unreadable poll is transient: the next period asks again.
69  if (status === null || !watch.tracked.has(one.id) || !one.polling) return
70  one.status = status
71  if (isFinal(status)) {
72    one.polling = false
73    one.auth = null
74    $.ui.toast(finalToast(one.id, status))
75  }
76}
77
78async function tick($: EngineInterface, watch: Watch) {
79  if (watch.busy) return
80  watch.busy = true
81  try {
82    const now = await $.clock.now()
83    for (const one of [...watch.tracked.values()].filter(entry => entry.polling)) await poll($, watch, one, now)
84  } finally {
85    watch.busy = false
86    stopTimerIfIdle(watch)
87    showStatus($, watch)
88  }
89}
90
91async function track($: EngineInterface, watch: Watch, id: string, file: string | null, status: string, auth: string[] | null) {
92  const polling = auth !== null
93  watch.tracked.set(id, { id, file, status, startedAt: await $.clock.now(), auth, polling, note: polling ? null : 'not polled' })
94  if (!polling) $.ui.toast(unpolledToast(id))
95  else watch.timer ??= $.clock.every(POLL_MS, () => void tick($, watch))
96  showStatus($, watch)
97}
98
99function stopAll($: EngineInterface, watch: Watch): { submissions: number; scripts: number } {
100  let submissions = 0
101  for (const one of watch.tracked.values()) {
102    if (!one.polling) continue
103    one.polling = false
104    one.auth = null
105    one.note = 'stopped'
106    submissions += 1
107  }
108  let scripts = 0
109  for (const run of watch.scripts) {
110    if (run.timer === null) continue
111    run.timer.cancel()
112    run.timer = null
113    run.note = 'stopped'
114    scripts += 1
115  }
116  stopTimerIfIdle(watch)
117  showStatus($, watch)
118
119  return { submissions, scripts }
120}
121
122const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
123
124function stoppedText(stopped: { submissions: number; scripts: number }): string {
125  const parts = [
126    stopped.submissions > 0 ? `polling ${plural(stopped.submissions, 'submission')}` : '',
127    stopped.scripts > 0 ? `watching history for ${plural(stopped.scripts, 'script run')}` : '',
128  ].filter(Boolean)
129
130  return parts.length === 0 ? 'Nothing was being polled.' : `Stopped ${parts.join(' and ')}.`
131}
132
133// The keychain profile of a script run: the keychainProfile option, else read from the script's text.
134async function profileOf($: EngineInterface, script: ScriptCall, configured: string): Promise<{ profile: string | null; from: ScriptRun['from'] }> {
135  if (configured !== '') return { profile: configured, from: 'keychainProfile' }
136  if (script.path === null) return { profile: null, from: null }
137  try {
138    const profile = scriptProfile(await $.fs.read(script.path), script.env)
139    return { profile, from: profile === null ? null : 'script' }
140  } catch {
141    return { profile: null, from: null }
142  }
143}
144
145// Asks `notarytool history` once and follows each submission created since the script started that is
146// not followed yet: polled like a direct submit, or only toasted when history already shows it final.
147async function scanHistory($: EngineInterface, watch: Watch, run: ScriptRun): Promise<boolean> {
148  if (run.profile === null) return false
149  let entries: ReturnType<typeof parseHistory> = null
150  try {
151    const ran = await $.process.run(['xcrun', 'notarytool', 'history', '--keychain-profile', run.profile, '--output-format', 'json'], { timeoutMs: INFO_MS })
152    entries = ran.exitCode === 0 ? parseHistory(ran.stdout) : null
153  } catch {
154    entries = null
155  }
156  if (entries === null) return false
157  for (const entry of newSubmissions(entries, run.startedAt)) {
158    // Already followed (a direct submit in the same command, an earlier scan): never twice.
159    if (watch.tracked.has(entry.id)) continue
160    run.found += 1
161    if (isFinal(entry.status)) {
162      watch.tracked.set(entry.id, { id: entry.id, file: entry.name, status: entry.status, startedAt: await $.clock.now(), auth: null, polling: false, note: `from ${run.name}` })
163      $.ui.toast(finalToast(entry.id, entry.status))
164    } else await track($, watch, entry.id, entry.name, entry.status, ['--keychain-profile', run.profile])
165  }
166
167  return true
168}
169
170async function scriptTick($: EngineInterface, watch: Watch, run: ScriptRun) {
171  if (run.busy || run.timer === null) return
172  run.busy = true
173  try {
174    const ok = await scanHistory($, watch, run)
175    const now = await $.clock.now()
176    if (now - run.startedAt >= SCRIPT_MAX_MS) {
177      run.timer?.cancel()
178      run.timer = null
179      run.note = 'watched history for 30 min'
180    } else run.note = ok ? 'watching history' : 'watching history (last check failed)'
181  } finally {
182    run.busy = false
183  }
184}
185
186// Runs after the script's Bash call has returned (never before its result).
187async function followScript($: EngineInterface, watch: Watch, script: ScriptCall, startedAt: number, background: boolean, configured: string) {
188  const run: ScriptRun = { name: script.name, startedAt, background, profile: null, from: null, found: 0, note: 'checking', timer: null, busy: false }
189  watch.scripts.push(run)
190  const { profile, from } = await profileOf($, script, configured)
191  run.profile = profile
192  run.from = from
193  if (profile === null) {
194    run.note = 'not checked: keychain profile unknown (set the keychainProfile option)'
195    return
196  }
197  if (background) {
198    run.note = 'watching history'
199    run.timer = $.clock.every(POLL_MS, () => void scriptTick($, watch, run))
200    return
201  }
202  run.note = (await scanHistory($, watch, run)) ? 'history checked' : 'history check failed'
203}
204
205function scriptLine(run: ScriptRun, now: number): string {
206  const profile = run.profile === null ? null : `profile ${run.profile}${run.from === 'script' ? ' (read from the script)' : ''}`
207  const found = run.profile === null ? null : plural(run.found, 'new submission')
208
209  return ['script', run.name, run.background ? 'background' : null, age(now - run.startedAt), profile, found, run.note].filter(Boolean).join(' · ')
210}
211
212async function listing($: EngineInterface, watch: Watch): Promise<string> {
213  if (watch.tracked.size === 0 && watch.scripts.length === 0) return 'No notarization submission tracked this session.'
214  const now = await $.clock.now()
215  const lines = [...watch.tracked.values()].map(one =>
216    [one.id, one.file, one.status, age(now - one.startedAt), one.polling ? 'polling' : one.note].filter(Boolean).join(' · '),
217  )
218
219  return [...lines, ...watch.scripts.map(run => scriptLine(run, now))].join('\n')
220}
221
222const option = (value: unknown, fallback: string) => (typeof value === 'string' ? value.trim() : fallback)
223
224export const register: Register = (on, options) => {
225  const watch: Watch = { tracked: new Map(), timer: null, busy: false, scripts: [] }
226  const scriptPattern = option(options?.scriptPattern, 'release')
227  const keychainProfile = option(options?.keychainProfile, '')
228
229  on('session.start', async ($, e, next) => {
230    await $.command.register({ name: 'notary-watch', description: 'List the notarization submissions followed this session (stop: stop polling)' })
231
232    return next(e)
233  })
234
235  on('tool.call', async ($, e, next) => {
236    // Synchronous checks first: any other call reaches next() with no await of this mod's.
237    const script = e.agentId === undefined && e.tool === 'Bash' ? scriptCall(e.command, scriptPattern) : null
238    const scriptStart = script === null ? 0 : await $.clock.now()
239    const ran = await next(e)
240    if (e.agentId !== undefined || e.tool !== 'Bash' || ran.deny !== undefined) return ran
241    if (script !== null) {
242      const output = ran.result as { backgroundTaskId?: unknown } | undefined
243      const background = e.run_in_background === true || typeof output?.backgroundTaskId === 'string'
244      // Deferred: the tool result is returned first, then history is asked.
245      $.clock.after(0, () => void followScript($, watch, script, scriptStart, background, keychainProfile))
246    }
247    const submit = parseSubmit(e.command)
248    if (submit === null) return ran
249    const result = ran.result as { stdout?: unknown; stderr?: unknown } | undefined
250    const text = [result?.stdout, result?.stderr].filter((part): part is string => typeof part === 'string').join('\n')
251    const { id, status } = parseOutput(text)
252    if (id === null) return ran
253    // notarytool may end a waited-for Invalid submission with a failing exit: its final status still counts.
254    if (submit.wait && isFinal(status)) {
255      watch.tracked.set(id, { id, file: submit.file, status, startedAt: await $.clock.now(), auth: null, polling: false, note: 'waited' })
256      $.ui.toast(finalToast(id, status))
257      return ran
258    }
259    // A parsed status means the upload went through, even when the call failed (a --wait cut short by
260    // --timeout or by the Bash tool); an id alone on a failed call may be a failed upload.
261    if ((status === null && ran.isError === true) || watch.tracked.get(id)?.polling === true) return ran
262    await track($, watch, id, submit.file, status ?? IN_PROGRESS, submit.canPoll ? submit.auth : null)
263
264    return ran
265  })
266
267  on('command.run', { command: 'notary-watch' }, async ($, e) => {
268    if (e.args.trim() === 'stop') {
269      return { text: stoppedText(stopAll($, watch)) }
270    }
271
272    return { text: await listing($, watch) }
273  })
274}
275
hooks/notary.ts 376 lines
1// Pure logic of notary-watch: reading `notarytool submit` command lines and notarytool's output.
2
3export const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
4export const FINAL = ['Accepted', 'Invalid', 'Rejected'] as const
5export type Final = (typeof FINAL)[number]
6export const IN_PROGRESS = 'In Progress'
7const STATUSES = [...FINAL, IN_PROGRESS]
8
9export type Submit = {
10  // The auth options to pass again to `notarytool info`, as argv (`--keychain-profile`, `X`, …).
11  auth: string[]
12  // Whether `auth` can be replayed without a shell or a prompt.
13  canPoll: boolean
14  wait: boolean
15  json: boolean
16  file: string | null
17}
18
19// Short and long spellings of the options that take a value, by long name.
20const VALUE_OPTIONS: Record<string, string> = {
21  '-k': '--key',
22  '-d': '--key-id',
23  '-i': '--issuer',
24  '-p': '--keychain-profile',
25  '-f': '--output-format',
26}
27const AUTH = new Set(['--key', '--key-id', '--issuer', '--apple-id', '--password', '--team-id', '--keychain-profile', '--keychain'])
28const OTHER_VALUED = new Set(['--output-format', '--webhook', '--timeout'])
29const PREFIXES = new Set(['rtk', 'env', 'time', 'command', 'exec', 'nohup', 'caffeinate'])
30
31type Token = { text: string; expands: boolean }
32
33// Splits a shell command into simple commands (on unquoted && || ; | & and newlines) of words, quotes removed.
34// A word that holds an unquoted or double-quoted `$` or a backquote is marked: its value is the shell's, not ours.
35export function simpleCommands(command: string): Token[][] {
36  const commands: Token[][] = [[]]
37  let word: Token | null = null
38  let quote: '"' | "'" | null = null
39  let redirect = false
40  const push = () => {
41    if (word !== null && redirect) redirect = false
42    else if (word !== null) commands[commands.length - 1]!.push(word)
43    word = null
44  }
45  const add = (ch: string, expands = false) => {
46    word ??= { text: '', expands: false }
47    word.text += ch
48    if (expands) word.expands = true
49  }
50  for (let i = 0; i < command.length; i += 1) {
51    const ch = command[i]!
52    if (quote === "'") {
53      if (ch === "'") quote = null
54      else add(ch)
55    } else if (quote === '"') {
56      if (ch === '"') quote = null
57      else if (ch === '\\' && i + 1 < command.length) add(command[++i]!)
58      else add(ch, ch === '$' || ch === '`')
59    } else if (ch === "'" || ch === '"') {
60      quote = ch
61      word ??= { text: '', expands: false }
62    } else if (ch === '\\' && i + 1 < command.length) {
63      if (command[i + 1] === '\n') i += 1
64      else add(command[++i]!)
65    } else if (/\s/.test(ch) && ch !== '\n') {
66      push()
67    } else if (ch === '>' || ch === '<') {
68      // A redirection is no word of the command: drop its fd, operator and target.
69      if (word !== null && /^\d+$/.test(word.text)) word = null
70      else push()
71      while (command[i + 1] === '>' || command[i + 1] === '<') i += 1
72      if (command[i + 1] === '&') {
73        i += 1
74        while (/[\d-]/.test(command[i + 1] ?? '')) i += 1
75      } else redirect = true
76    } else if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
77      push()
78      if (commands[commands.length - 1]!.length > 0) commands.push([])
79    } else {
80      add(ch, ch === '$' || ch === '`')
81    }
82  }
83  push()
84
85  return commands.filter(words => words.length > 0)
86}
87
88const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
89
90// The words after `notarytool submit` when this simple command runs it, else null.
91function submitArgs(words: Token[]): Token[] | null {
92  let i = 0
93  while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!.text) || PREFIXES.has(baseName(words[i]!.text)))) i += 1
94  if (i < words.length && baseName(words[i]!.text) === 'xcrun') {
95    i += 1
96    while (i < words.length && words[i]!.text.startsWith('-')) i += words[i]!.text === '--sdk' || words[i]!.text === '--toolchain' ? 2 : 1
97  }
98  if (baseName(words[i]?.text ?? '') !== 'notarytool' || words[i + 1]?.text !== 'submit') return null
99
100  return words.slice(i + 2)
101}
102
103function canReplay(auth: Map<string, Token>): boolean {
104  if ([...auth.values()].some(token => token.expands)) return false
105  if (auth.has('--keychain-profile')) return true
106  if (auth.has('--key') && auth.has('--key-id')) return true
107
108  return auth.has('--apple-id') && auth.has('--team-id') && auth.has('--password')
109}
110
111// Reads a command line that runs `xcrun notarytool submit …` (behind rtk, env assignments, `cd x &&`, …).
112export function parseSubmit(command: string): Submit | null {
113  for (const words of simpleCommands(command)) {
114    const args = submitArgs(words)
115    if (args === null) continue
116    const auth = new Map<string, Token>()
117    let wait = false
118    let format = 'normal'
119    let file: string | null = null
120    for (let i = 0; i < args.length; i += 1) {
121      const raw = args[i]!.text
122      const eq = raw.startsWith('-') ? raw.indexOf('=') : -1
123      const flag = eq > 0 ? raw.slice(0, eq) : raw
124      const name = VALUE_OPTIONS[flag] ?? flag
125      if (AUTH.has(name) || OTHER_VALUED.has(name)) {
126        const value = eq > 0 ? { text: raw.slice(eq + 1), expands: args[i]!.expands } : args[++i]
127        if (value === undefined) break
128        if (AUTH.has(name)) auth.set(name, value)
129        else if (name === '--output-format') format = value.text
130      } else if (name === '--wait') wait = true
131      else if (name === '--no-wait') wait = false
132      else if (!raw.startsWith('-')) file = raw
133    }
134    const authArgv = [...auth].flatMap(([key, token]) => [key, token.text])
135
136    return { auth: authArgv, canPoll: canReplay(auth), wait, json: format === 'json', file }
137  }
138
139  return null
140}
141
142// Every JSON object notarytool may have printed: the whole text, each line, then the outermost braces.
143function jsonObjects(text: string): Record<string, unknown>[] {
144  const candidates = [text.trim(), ...text.split('\n').map(line => line.trim()).filter(line => line.startsWith('{'))]
145  const first = text.indexOf('{')
146  const last = text.lastIndexOf('}')
147  if (first >= 0 && last > first) candidates.push(text.slice(first, last + 1))
148  const found: Record<string, unknown>[] = []
149  for (const candidate of candidates) {
150    try {
151      const value: unknown = JSON.parse(candidate)
152      if (value !== null && typeof value === 'object' && !Array.isArray(value)) found.push(value as Record<string, unknown>)
153    } catch {
154      // not JSON
155    }
156  }
157
158  return found
159}
160
161const isStatus = (value: unknown): value is string => typeof value === 'string' && STATUSES.includes(value)
162
163// The submission id and last status in notarytool's output: its JSON `id`/`status` keys, else the
164// UUID after an `id:` field label and the value after a `status:` label (field names, never sentences).
165export function parseOutput(text: string): { id: string | null; status: string | null } {
166  for (const object of jsonObjects(text)) {
167    if (typeof object.id === 'string' && new RegExp(`^${UUID.source}$`, 'i').test(object.id)) {
168      return { id: object.id, status: isStatus(object.status) ? object.status : null }
169    }
170  }
171  const id = new RegExp(`(?:^|\\s)id:\\s*(${UUID.source})`, 'i').exec(text)?.[1] ?? null
172  const statuses = [...text.matchAll(/(?:^|\s)status:\s*(Accepted|In Progress|Invalid|Rejected)\b/g)].map(match => match[1]!)
173
174  return { id, status: id === null ? null : (statuses.at(-1) ?? null) }
175}
176
177// The status in `notarytool info <id> --output-format json`, or null when it is not that.
178export function parseInfo(stdout: string): string | null {
179  for (const object of jsonObjects(stdout)) if (isStatus(object.status)) return object.status
180
181  return null
182}
183
184export const isFinal = (status: string | null): status is Final => status !== null && (FINAL as readonly string[]).includes(status)
185
186export const shortId = (id: string) => id.slice(0, 8)
187
188export function finalToast(id: string, status: Final): string {
189  if (status === 'Accepted') return `notary ✓ accepted — now staple (${shortId(id)})`
190
191  return `notary ✗ ${status.toLowerCase()} (${shortId(id)}) — see why: xcrun notarytool log ${id} <auth>`
192}
193
194export const unpolledToast = (id: string) =>
195  `notary: ${shortId(id)} submitted — not polled (auth comes from the shell or a prompt); check with xcrun notarytool info ${id} <auth>`
196
197export const timedOutToast = (id: string) => `notary: stopped watching ${shortId(id)} after 2h (still ${IN_PROGRESS})`
198
199// The status line for the submissions still polled (newest last), or undefined to clear it.
200export function statusLine(polled: { id: string }[]): string | undefined {
201  if (polled.length === 0) return undefined
202  if (polled.length === 1) return `notary: ${shortId(polled[0]!.id)} ${IN_PROGRESS}`
203
204  return `notary: ${polled.length} in progress`
205}
206
207export function age(ms: number): string {
208  const minutes = Math.floor(ms / 60_000)
209
210  return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
211}
212
213// ── Release scripts ──────────────────────────────────────────────────────────
214
215export type ScriptCall = {
216  // The script's path as the session can read it (a `cd x &&` before it applied), or null when unknown.
217  path: string | null
218  name: string
219  // `VAR=value` assignments written before the script on the command line (literal values only).
220  env: Record<string, string>
221}
222
223const INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'source', '.'])
224const SCRIPT_EXT = /\.(?:sh|bash|zsh|command)$/i
225const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/
226
227function joinPath(dir: string | null, path: string): string | null {
228  if (path.startsWith('/')) return path
229  if (dir === null || path.startsWith('~')) return null
230
231  return dir === '' ? path : `${dir.replace(/\/+$/, '')}/${path}`
232}
233
234// The release script a command runs, in command position only: the command itself when it is a path
235// (`./Scripts/release.sh`, `Scripts/release.sh`, `release.sh`) or the script given to bash/sh/zsh/source/.
236// Its basename must contain `pattern` (case-insensitive); an empty pattern turns this off.
237export function scriptCall(command: string, pattern: string): ScriptCall | null {
238  const needle = pattern.trim().toLowerCase()
239  if (needle === '') return null
240  let dir: string | null = ''
241  for (const words of simpleCommands(command)) {
242    const env: Record<string, string> = {}
243    let i = 0
244    for (; i < words.length; i += 1) {
245      const assignment = ASSIGNMENT.exec(words[i]!.text)
246      if (assignment !== null) {
247        if (!words[i]!.expands) env[assignment[1]!] = assignment[2]!
248      } else if (!PREFIXES.has(baseName(words[i]!.text))) break
249    }
250    const head = words[i]
251    if (head === undefined) continue
252    if (head.text === 'cd' || head.text === 'pushd') {
253      const target = words[i + 1]
254      dir = target === undefined || target.expands || target.text === '-' ? null : joinPath(dir, target.text)
255      continue
256    }
257    let candidate: Token | undefined
258    if (INTERPRETERS.has(baseName(head.text))) {
259      let j = i + 1
260      while (j < words.length && words[j]!.text.startsWith('-')) j += 1
261      candidate = words[j]
262    } else if (head.text.includes('/') || SCRIPT_EXT.test(head.text)) candidate = head
263    if (candidate === undefined || candidate.expands) continue
264    const name = baseName(candidate.text)
265    if (name.toLowerCase().includes(needle)) return { path: joinPath(dir, candidate.text), name, env }
266  }
267
268  return null
269}
270
271const VALUE = `("[^"\\n]*"|'[^'\\n]*'|[^\\s;&|)]+)`
272
273// The script without its comments: a header comment may name another profile than the code uses.
274function withoutComments(text: string): string {
275  return text
276    .split('\n')
277    .map(line => (line.trimStart().startsWith('#') ? '' : line.replace(/\s#.*$/, '')))
278    .join('\n')
279}
280
281function unquote(raw: string): { text: string; literal: boolean } {
282  if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return { text: raw.slice(1, -1), literal: true }
283  const text = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
284
285  return { text, literal: !/[$`]/.test(text) }
286}
287
288const validProfile = (value: string) => (value.trim() !== '' && !/[$`\\"'\n]/.test(value) ? value.trim() : null)
289
290// `${VAR:-default}`, `${VAR-default}`, `${VAR}` or `$VAR`: the variable and its literal default.
291function reference(text: string): { name: string; fallback: string | null } | null {
292  const match = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}?$/.exec(text)
293  if (match === null) return null
294
295  return { name: match[1]!, fallback: match[2] === undefined ? null : validProfile(match[2]) }
296}
297
298// The value a variable takes in the script: the command line's own assignment first, then the
299// script's first assignment whose value is a literal or a `${VAR:-literal}` default.
300function variable(name: string, text: string, env: Record<string, string>): string | null {
301  if (env[name] !== undefined) return validProfile(env[name])
302  const assignments = new RegExp(`(?:^|[\\s;&(])(?:(?:export|readonly|local|declare(?:\\s+-[A-Za-z]+)*)\\s+)?${name}=${VALUE}`, 'gm')
303  for (const match of text.matchAll(assignments)) {
304    const value = unquote(match[1]!)
305    if (value.literal) return validProfile(value.text)
306    const ref = reference(value.text)
307    if (ref === null) continue
308    const given = env[ref.name]
309    if (given !== undefined) return validProfile(given)
310    if (ref.fallback !== null) return ref.fallback
311  }
312
313  return null
314}
315
316function resolveValue(raw: string, text: string, env: Record<string, string>): string | null {
317  const value = unquote(raw)
318  if (value.literal) return validProfile(value.text)
319  const ref = reference(value.text)
320  if (ref === null) return null
321
322  const given = env[ref.name]
323
324  return given !== undefined ? validProfile(given) : (variable(ref.name, text, env) ?? ref.fallback)
325}
326
327// The notarytool keychain profile a release script uses, read from its text: the `--keychain-profile`
328// values (resolved through `VAR="${VAR:-name}"`-style defaults), else a `*PROFILE*="${…:-name}"` default.
329// Null when none is found, when one cannot be resolved, or when the script names several.
330export function scriptProfile(script: string, env: Record<string, string> = {}): string | null {
331  const text = withoutComments(script)
332  const found = new Set<string>()
333  let unresolved = false
334  for (const match of text.matchAll(new RegExp(`--keychain-profile(?:=|[ \\t]+)${VALUE}`, 'g'))) {
335    const value = resolveValue(match[1]!, text, env)
336    if (value === null) unresolved = true
337    else found.add(value)
338  }
339  if (unresolved || found.size > 1) return null
340  if (found.size === 1) return [...found][0]!
341  for (const match of text.matchAll(/([A-Za-z_][A-Za-z0-9_]*)=["']?\$\{([A-Za-z_][A-Za-z0-9_]*):?-([^}"'\s]+)\}/g)) {
342    if (match[1] !== match[2] || !/profile/i.test(match[1]!)) continue
343    const value = env[match[1]!] !== undefined ? validProfile(env[match[1]!]!) : validProfile(match[3]!)
344    if (value !== null) found.add(value)
345  }
346
347  return found.size === 1 ? [...found][0]! : null
348}
349
350export type HistoryEntry = { id: string; name: string | null; status: string; createdMs: number }
351
352// The submissions in `notarytool history --output-format json` (`{ "history": [ { createdDate, id, name,
353// status } ], "message" }`), or null when the output is not that JSON. A missing `history` is no submission.
354export function parseHistory(stdout: string): HistoryEntry[] | null {
355  const object = jsonObjects(stdout).find(value => Array.isArray(value.history) || typeof value.message === 'string')
356  if (object === undefined) return null
357  const entries: HistoryEntry[] = []
358  for (const item of Array.isArray(object.history) ? (object.history as unknown[]) : []) {
359    if (item === null || typeof item !== 'object') continue
360    const { id, name, status, createdDate } = item as Record<string, unknown>
361    if (typeof id !== 'string' || !new RegExp(`^${UUID.source}$`, 'i').test(id) || !isStatus(status) || typeof createdDate !== 'string') continue
362    const createdMs = Date.parse(createdDate)
363    if (Number.isNaN(createdMs)) continue
364    entries.push({ id, name: typeof name === 'string' ? name : null, status, createdMs })
365  }
366
367  return entries
368}
369
370export const HISTORY_SLACK_MS = 60_000
371
372// The submissions created since `sinceMs`, less a minute of slack for the clocks' drift, oldest first.
373export function newSubmissions(entries: HistoryEntry[], sinceMs: number): HistoryEntry[] {
374  return entries.filter(entry => entry.createdMs >= sinceMs - HISTORY_SLACK_MS).sort((a, b) => a.createdMs - b.createdMs)
375}
376