Receives app release packages from R2 (releases/<app>/<version>/) into the website: checks them against docs/release-standard.md, pulls them down, and hands…

hooks/register.ts 146 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3
4/* The website's end of docs/release-standard.md.
5
6 An app repo's /release puts a package under releases/<app>/<version>/ in
7 the R2 bucket and tells this chat. Here, /receive lists what is waiting,
8 checks a package against §2 of the standard, pulls it into
9 release-inbox/<app>/<version>/ (ignored by git), and hands the model the
10 hand-off notes with the standard's §6 as its instructions. The model does
11 the website work; this module never writes to the catalog, the bucket's
12 <app>/ folders, or main. */
13
14const MUST = ['handoff.md', 'brag/brag.mp4', 'brag/brag.jpg', 'brag/share-copy.txt']
15
16type ReleaseRef = { app: string; version: string; path: string }
17
18const waiting = atom({ plugin: 'release-receiver', key: 'waiting' } as const, [] as readonly ReleaseRef[])
19const current = atom({ plugin: 'release-receiver', key: 'current' } as const, null as ReleaseRef | null)
20
21let root = 'r2:amanorsac-downloads/releases'
22let bucket = 'amanorsac-downloads'
23
24async function rclone($: EngineInterface, args: readonly string[], timeoutMs = 120_000) {
25 const ran = await $.process.run(['rclone', ...args], { timeoutMs })
26 if (ran.exitCode !== 0) throw new Error(`rclone ${args[0]} failed: ${ran.stderr.trim().slice(0, 300)}`)
27 return ran.stdout
28}
29
30/* releases/<app>/<version>/ as { app, version, path }. */
31async function scan($: EngineInterface): Promise<ReleaseRef[]> {
32 const out = await rclone($, ['lsjson', '--dirs-only', '-R', '--max-depth', '2', root])
33 const dirs = (JSON.parse(out) as { Path: string; IsDir: boolean }[]).filter(d => d.IsDir && d.Path.includes('/'))
34 return dirs.map(d => {
35 const [app, version] = d.Path.split('/')
36 return { app, version, path: `${root}/${d.Path}` }
37 })
38}
39
40export const register: Register = (on, options) => {
41 const remote = String(options.remote ?? 'r2')
42 bucket = String(options.bucket ?? 'amanorsac-downloads')
43 root = `${remote}:${bucket}/releases`
44
45 on('session.start', async ($, e, next) => {
46 await $.command.register({
47 name: 'receive',
48 description: 'Pick up an app release package from R2 and hand its notes to the model',
49 argumentHint: '[app version] | list',
50 })
51 const started = await next(e)
52 if (!e.isInteractive) return started
53 // A look at the bucket on start, so a release that landed while this chat
54 // was closed is on the status line before anyone asks.
55 void (async () => {
56 try {
57 const found = await scan($)
58 await update($, waiting, () => found)
59 if (found.length) {
60 $.ui.status(`${found.length} release${found.length === 1 ? '' : 's'} waiting: /receive`)
61 $.ui.toast(`Release${found.length === 1 ? '' : 's'} waiting in R2: ${found.map(r => `${r.app} ${r.version}`).join(', ')}. Run /receive.`)
62 }
63 } catch (err) {
64 $.ui.log(`release-receiver: could not list ${root}: ${(err as Error).message}`, { to: 'debug' })
65 }
66 })()
67 return started
68 })
69
70 on('command.run', { command: 'receive' }, async ($, e) => {
71 const args = e.args.trim().split(/\s+/).filter(Boolean)
72
73 let found: ReleaseRef[]
74 try { found = await scan($) }
75 catch (err) { return { text: `Could not list ${root}: ${(err as Error).message}` } }
76 await update($, waiting, () => found)
77
78 if (!found.length) {
79 $.ui.status(undefined)
80 return { text: `Nothing waiting under ${root}.` }
81 }
82 if (!args.length || args[0] === 'list') {
83 return { text: 'Waiting in R2:\n' + found.map(r => ` ${r.app} ${r.version}`).join('\n') + '\n\nRun /receive <app> <version> to pick one up.' }
84 }
85 const [app, version] = args
86 const ref = found.find(r => r.app === app && r.version === version)
87 if (!ref) return { text: `No releases/${app}/${version}/ in the bucket. /receive list shows what is there.` }
88
89 /* §2 of the standard: what MUST be in the package. */
90 const listing = JSON.parse(await rclone($, ['lsjson', '-R', ref.path])) as { Path: string; Size: number; IsDir: boolean }[]
91 const files = listing.filter(f => !f.IsDir)
92 const names = new Set(files.map(f => f.Path))
93 const missing = MUST.filter(m => !names.has(m))
94 const installers = files.filter(f => f.Path.startsWith('installers/'))
95 const shots = files.filter(f => /^screenshots\/\d\d-.+\.png$/.test(f.Path))
96 if (!installers.length) missing.push('installers/ (at least one)')
97 if (!shots.length) missing.push('screenshots/NN-<what>.png (at least one)')
98 if (missing.length) {
99 return { text: `releases/${app}/${version}/ is not a complete package (docs/release-standard.md §2). Missing:\n` + missing.map(m => ` ${m}`).join('\n') + '\n\nNothing was pulled down. Ask the app repo to finish the package and run /receive again.' }
100 }
101
102 /* Pull it into the inbox, which .git/info/exclude keeps out of commits. */
103 const cwd = await $.session.cwd()
104 const inbox = `release-inbox/${app}/${version}`
105 try {
106 const excl = await $.fs.read('.git/info/exclude').catch(() => '')
107 if (!excl.includes('release-inbox/')) await $.fs.write('.git/info/exclude', excl.replace(/\s*$/, '\n') + 'release-inbox/\n')
108 } catch {}
109 try { await rclone($, ['copy', ref.path, `${cwd}/${inbox}`, '--transfers', '4'], 600_000) }
110 catch (err) { return { text: `Pulling the package failed: ${(err as Error).message}` } }
111
112 const handoff = await $.fs.read(`${inbox}/handoff.md`)
113 const sizes = installers.map(f => ` ${f.Path.replace('installers/', '')} ${f.Size} bytes`).join('\n')
114 await update($, current, () => ref)
115 await update($, waiting, list => list.filter(r => r.path !== ref.path))
116 $.ui.status(`receiving ${app} ${version}`)
117
118 const text =
119 `Pulled releases/${app}/${version}/ into ${inbox}/ (${files.length} files).\n\nInstallers in the package:\n${sizes}\n\nThe hand-off notes follow for the model. Nothing on the site has changed yet.`
120 const context = [
121 `A release package for "${app}" version ${version} is in ${inbox}/ and its notes are below. Follow docs/release-standard.md §6 exactly, in this order:\n` +
122 `1. Read the notes. If anything in §2 is missing or the notes are incomplete, stop and say what is missing.\n` +
123 `2. Copy each installer to its catalog name under ${bucket}/${app}/ with rclone copyto (never overwrite a name the catalog already serves; new names carry the version), then rclone lsl that folder and compare byte counts to the package's. Only after the listing confirms them may catalog.json change; keep the previous keys as alt_keys.\n` +
124 `3. Convert screenshots to webp at 1600 and 800 wide into images/apps/${app}/; copy brag/brag.mp4 to video/${app}-promo.mp4 and put it on the app's page muted, looped, no controls, playing only while on screen, like easystems.html does.\n` +
125 `4. Set the catalog version, add a "New in ${version}" section to the page from What's new, add a news entry, and add a notice block only if the notes' "Email owners" says yes.\n` +
126 `5. Commit and push the working branch, run the site checks.\n` +
127 `6. Report what changed and ask two questions: the price (or confirm unchanged) and the go-ahead. Do NOT set a price, change a status to available, or push main until both are answered.\n` +
128 `Secrets rule stands: no service_role, signing or payment secret keys in any file.\n\n--- handoff.md ---\n${handoff}`,
129 ]
130 return { text, context }
131 })
132
133 /* Every website session knows the standard exists and where the inbox is. */
134 on('prompt.compose', async ($, e, next) => {
135 const { sections } = await next(e)
136 const now = await read($, current)
137 const queue = await read($, waiting)
138 const text =
139 `Release handling for this website repo is governed by docs/release-standard.md. App repos put release packages under ${root}/<app>/<version>/; /receive <app> <version> pulls one into release-inbox/ and hands over its notes. ` +
140 (now ? `A release is being received now: ${now.app} ${now.version} (release-inbox/${now.app}/${now.version}/). ` : '') +
141 (queue.length ? `Waiting in the bucket: ${queue.map(r => `${r.app} ${r.version}`).join(', ')}. ` : '') +
142 `Nothing goes live (no price set, no status to available, no push to main) without the person's explicit go-ahead and price.`
143 return { sections: [...sections, { id: 'release-receiver:standard', text, scope: 'session' }] }
144 })
145}
146types/index.d.ts 8 lines1export type ReleaseRef = { app: string; version: string; path: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'release-receiver': { waiting: ReleaseRef[]; current: ReleaseRef | null }
6 }
7}
8