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

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.
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.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.--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)--wait, or when --timeout ended the wait while still in progress:notary: 1a2b3c4d In Progress (notary: N in progress for several)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)$.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.--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.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.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.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.run_in_background, Ctrl+B, or moved there on timeout) returns at once: history is then asked every 60 s for 30 min.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.| Option | Default | Effect |
|---|---|---|
scriptPattern | release | Text 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" } } } }
| Command | Effect |
|---|---|
/notary-watch | Lists 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 stop | Stops all polling, including background history watching, and clears the status line |
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
claude --plugin-dir /path/to/ModsTools/mods/notary-watch
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.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.history check; if it fails (timeout, network), /notary-watch says so and nothing is retried. Background runs are watched for 30 min only.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).--key path given after a cd may not resolve.Accepted, In Progress, Invalid, Rejected); English sentences in its output are never matched.claude plugin validate mods/notary-watch
claude plugin test mods/notary-watch # 29 testshooks/register.ts 275 lines1import 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}
275hooks/notary.ts 376 lines1// 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