SLOPSHOPPER

release-receiver

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

newcommandtoaststatuspromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · release-receiver
› fix the failing auth test and add an audit log call ● release-receiver: release-receiver: could not list r2:amanorsac-downloads/releases: JSON Parse error: Unexpected EOF ⏺ 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 › /receive ⎿ release-receiver: Could not list r2:amanorsac-downloads/releases: JSON Parse error: Unexpected EOF ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

potfoliowebsite

Source 2 files
hooks/register.ts 146 lines
1import { 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}
146
types/index.d.ts 8 lines
1export 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