Get Heard

Contributing to Get Heard

Thanks for looking. This file is for someone who wants to change the code - how to run it, how the project actually works, and which conventions are enforced by something that will fail rather than by someone remembering.

The license, in the two sentences that affect you

Get Heard is AGPL-3.0-only. Run it for yourself, for your own music, at any scale, and nothing is asked of you - there is no obligation attached to using it. But if you modify it and let other people use your modified version over a network, you have to offer them its source (§13, "Remote Network Interaction"), and any derivative you distribute is AGPL too.

That is deliberate. This codebase is a hosted product as well as a program you run on your laptop, and the point of the copyleft is that a modified hosted fork stays open rather than becoming somebody else's closed SaaS. It does not stop anyone hosting Get Heard unmodified - that is a permitted use of an open-source license, and the trade was accepted knowingly.

Two consequences for a contributor:

What this is

A music promotion tool for one working artist: it finds outlets, writes and sends the pitches, tracks what came back, and produces the artifacts a release needs - a press kit, a smart link, a link-in-bio page, a fan kit, and 9:16 shorts cut from the song itself.

Everything in it is judged against one question, and the question has teeth in code review: does this get the song heard? A surface where a stranger lands and cannot press play is a bug, whatever else it does well. Reach without listening is vanity. When a metric and a listen point in different directions, the listen wins. If a change cannot be argued for on those grounds, it probably does not belong - and if it can, say so in the PR, because that is the argument that settles disagreements here.

It builds as one of two editions, forked in exactly one named place (config.edition, set by APP_EDITION): selfhost, the single-owner app above, which is the default and what every existing install is; and saas, a hosted multi-tenant product on top of it. The iron rule is that anything reading config.edition keeps selfhost byte-identical to before.

Why half of it says getheard

The product is Get Heard. The repository, the systemd units in deploy/, the service user and - load-bearing - the default database filename data/getheard.db are still getheard, and that is a decision rather than an unfinished rename. Every existing install has its database at that path, and renaming it would strand them for no gain, because no stranger ever reads any of those. The npm package is getheard, because publishing is precisely a stranger reading and typing a name.

Two traps live here. First: never mass-rename "out now" in this repo. OUT NOW is the release-status chip baked into every reel and it is press-kit and link-page copy - it is domain language about the song, nothing to do with the product's name, and a find/replace across it breaks the renderer and the published pages. Second, the general form, which is worth more than the specific case: a name's justification can expire without the name changing. "No stranger ever reads it" was true of the package name right up until the day it was published. When a surface stops being internal, re-derive the decision instead of inheriting it.

Getting it running

git clone <this repo> && cd getheard
npm install
npm run dev            # server watch + frontend bundle watch → http://localhost:3000

Node 24 or newer - not a preference. The app is native ESM and runs its TypeScript through Node's own type stripping, and package.json pins engines.node >= 24.

If you change Node versions, run npm rebuild better-sqlite3. It is compiled against whichever major last installed it, and the mismatch does not present as an error: some test files simply hang, which reads as "the suite is pathologically slow". That misdiagnosis cost two rounds once.

No configuration is needed to start. The app boots with nothing set and says what is missing, and most credentials (Netlify, Spotify, the Gmail OAuth app, a mailbox) are settable in Settings, where a saved value beats the matching env var. ⚠ The AI credentials are the exception and are read from the environment only - Settings picks the provider and the models. .env.example lists every variable, commented out; DOCS.md §10 is the reference it chains onto.

The commands

Command What it does
npm run dev The normal loop: tsx watch on the server + esbuild --watch on the frontend bundle
npm run dev:saas The hosted edition locally - its own database, its own port, publishing and mail disarmed. It refuses to start against the selfhost database
npm run dev:landing The hosted edition's signed-out front door, on its own database so your dev:saas session does not reach it. Seeds a synthetic showcase so the play button exists
npm run dev:crew Crew, the internal back-office - a separate process on its own port, against the same dev database as dev:saas, so the two run side by side
npm test The whole suite (node --test, native type stripping, no tsx). It must stay green. Runs the two below in order, because one concurrency number cannot serve both
npm run test:unit Everything except the browser tests, at high concurrency - they are cheap
npm run test:one tests/foo.test.ts One file, ~0.3s - the tight loop. ⚠ Not a bare node --test: the guard that stops the suite reaching the internet rides on --import ./tests/noNetwork.ts
npm run test:ui The real-browser tests only. Needs Chrome installed; without it they skip rather than fail. ⚠ Deliberately low concurrency: each file is a test runner, a real server child AND a Chrome, so running many at once makes the suite slower and flaky
npx tsc --noEmit -p tsconfig.json Backend typecheck
npx tsc --noEmit -p tsconfig.frontend.json Frontend typecheck - esbuild does not typecheck, so this is the only thing that will tell you
npm run typecheck:tests Typechecks tests/. ⚠ This and npm run lint are two gates and you need both - an eslint autofix once removed a load-bearing defaulted parameter that only tsc caught
npm run lint eslint in --fix mode over shared/, src/ and tests/
npm run prettier Formatting, scoped to code globs on purpose (see below)
npm run i18n:audit Every untranslated frontend string; --strict exits 1
npm run build then npm start The compiled build - the path an installed copy takes, and the only one an npx getheard user ever runs

Before opening a PR: npm test, both typechecks, and npm run lint.

How this project works

Work happens in rounds. Each one leaves a plans/PLAN-round<N>.md saying what was built, why, what was proved, and what is still on the list. They are the project's memory, they are frozen once written, and reading the newest one is the fastest way to find out where things stand.

BACKLOG.md is the durable cross-round backlog - the index, the current truth, and a pointer to the PLAN that holds each item's full spec. A round's "still on the list" is a short delta against it, not a fresh re-summary; that rule exists because the outstanding-work list used to live only in PLAN prose and a whole backlog quietly evaporated once.

The architecture document is CLAUDE.md, and it is addressed to an AI agent

That is deliberate and it is not going to change. Most of this codebase was written in sessions with an AI agent, and CLAUDE.md is the file each session boots from - so it is the one document with a structural reason to stay true, and it is where every hard-won reason ends up. Extracting an ARCHITECTURE.md from it would produce two documents, and the copy that stays true is the one a session boots from; the other would be stale within a month and would look authoritative while being wrong.

So: read CLAUDE.md as the architecture document. The voice is directed at an agent and it is full of ⚠ warnings written the day something bit; that is the good part, not an artefact. It keeps what binds work anywhere in the tree; the long reasoning for each individual lane sits beside the code it binds, and CLAUDE.md opens with a table pointing at every one:

Every one of those is registered in tests/docs.test.ts, so a note that names a file which no longer exists fails the suite rather than quietly going stale - and so does a note nobody registered, which would otherwise be checked by nothing at all.

The split is not a style choice; it is a budget. CLAUDE.md is loaded into every session before anyone knows what that session is about, so length in it is charged to every piece of work, including all the work that never opens the lane a given page describes. It has twice grown past what can actually be loaded, and both times nobody noticed until it was already over - so the sizes are gated now (see the table below). If a gate stops you, the fix is never to delete the reasoning: move the lane into docs/notes/<lane>.md, register it, and leave a pointer behind.

Conventions that are actually enforced

The useful list is not "what we prefer" but "what will fail". Almost everything here has a gate, because this codebase has repeatedly learned that a convention nothing checks is a convention that decays one round at a time.

Rule What enforces it
Relative imports carry an explicit .ts; type-only imports are import type; no enums, namespaces or constructor parameter properties; import.meta.dirname, never __dirname tsc (verbatimModuleSyntax, erasableSyntaxOnly) + an eslint autofix rule
TypeScript strict everywhere; strictTypeChecked on src/**, shared/** and tests/** npm run lint
No truthiness test on a non-boolean, in shared/** or a migrated frontend island (src/** is a per-file burn-down) npm run lint (strict-boolean-expressions)
Tabs, width 150, double quotes, arrowParens: "avoid", no trailing commas npm run prettier
No untranslated user-facing string in the frontend, in services, or in a shared catalog constant npm run i18n:audit, gated by tests/i18nAudit.test.ts
Every err.* code exists in both locale files, and every literal code the AppError family emits is a dictionary key or a declared diagnostic tests/errors.test.ts
No duplicate key in a locale file tests/i18n.test.ts
Icons are SVG the app owns, never emoji tests/icons.test.ts
Color is a token; a derived color is computed, not pasted tests/theme.test.ts
A component's box spacing goes inside its shadow root, never on :host tests/theme.test.ts
Every animation duration is a token, so prefers-reduced-motion can reach it tests/motion.test.ts
Every background timer goes through startSweep and is registered and started tests/sweeps.test.ts
Every query touching a tenant-scoped table filters by workspace tests/tenancyScope.test.ts
Every settings key has a declared disposition tests/settingsScope.test.ts
A daemon that reaches AI is wrapped in a workspace tests/daemonAiScope.test.ts
The test suite never touches the internet, and every test that can reach the un-publish path has the provider credential dead or the vendor stubbed tests/networkGuard.test.ts (+ the tests/noNetwork.ts guard every script that runs the test runner loads)
Every Crew route is audited or dispositioned; Crew touches only staff_* tables and never the process-local diagnostics tests/crewScope.test.ts
Every "what day/hour is it now" says whose clock it is; SQLite's 'localtime' is gone from src/ tests/workspaceClock.test.ts
Every audio_files query says which recording it means - a release can hold a master and an instrumental tests/audioKind.test.ts
Everything a workspace purge destroys is either carried away by the export and put back by the import, or has a written reason why not tests/dataPortability.test.ts
English here means American English - color, catalog, canceled, center, license - in the dictionary, on the published pages and in the legal documents tests/americanEnglish.test.ts
Every font-size in the app is a rem and nothing else is, so the text-size control can reach it; every element with static styles carries focusStyles tests/a11y.test.ts
Nothing in src/ or shared/ reads process.cwd() to find the app's own tree, and the published tarball contains what it should tests/packaging.test.ts
Only the modules that are allowed to know about a hosting vendor import one, in both directions tests/pagesProvider.test.ts
A hosted publish never runs unscoped - it would deploy one tenant's tree over another's tests/hubPublishScope.test.ts
An outlet's contact address never leaves the server in a hosted payload tests/outletAddress.test.ts
The wordmark renders as "Get Heard" with the space, even split across markup tests/brand.test.ts
A doc's claims about the app - env vars, file paths, npm scripts, unbuilt-feature claims tests/docs.test.ts
The notes an agent session loads stay inside their size budget, and every notes file is registered tests/docs.test.ts
Every way of building the frontend bundle pins the same esbuild --target, and what npm run dev writes parses as JavaScript tests/frontendBuildTarget.test.ts

The canary pattern, and how to answer one

Half the tests above are canaries: they guard a failure that has no symptom. An unscoped query returns the right rows in selfhost. A hardcoded color is exactly right in the theme it was written in. A scheduler that is never called does not log anything. A clock reading is the same "today" for every timezone the test suite happens to run in. A stale line in a document describes a feature that degrades gracefully, so nothing goes wrong - it just never gets used.

They all have the same shape, and the same failure message: a closed list, an entry per case, and a reason. When one fails, the answer is not to add your case to the exemption list and move on - though keep, global by design and not ours are all perfectly good answers. The rule is: unconsidered is not an answer. Write down which one it is and why, in the entry. The next person to read it is trusting that you looked.

If you add a thing the canary counts - a new sweep, a new table, a new env var, a new settings key

Things that will surprise you

Every one of these cost someone a day. CLAUDE.md's "Gotchas that have bitten" is the long list.

What a good PR looks like

If you are unsure whether something is wanted, open an issue before building it. BACKLOG.md is the real roadmap and most of its entries say why they are still open.

Reporting a security problem

Do not open a public issue. Write to security@getheard.now with enough detail to reproduce, and give it a reasonable window before disclosing. This app holds an artist's outreach history, the email addresses of editors who never signed up for anything, and OAuth tokens for their mail and social accounts - the stakes are somebody's real inbox, not a demo.