@profullstack/builtindie 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/OpenStack.md +74 -0
  3. package/README.md +63 -2
  4. package/bin/builtindie +3 -0
  5. package/package.json +35 -4
  6. package/public/app.js +35 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icon-192.png +0 -0
  9. package/public/icon-512.png +0 -0
  10. package/public/icons/openicon.json +574 -0
  11. package/public/icons/svg/apple.svg +1 -0
  12. package/public/icons/svg/arrow-up.svg +1 -0
  13. package/public/icons/svg/bluesky.svg +1 -0
  14. package/public/icons/svg/check.svg +1 -0
  15. package/public/icons/svg/chrome.svg +1 -0
  16. package/public/icons/svg/code.svg +1 -0
  17. package/public/icons/svg/discord.svg +1 -0
  18. package/public/icons/svg/dollar.svg +1 -0
  19. package/public/icons/svg/external-link.svg +1 -0
  20. package/public/icons/svg/github.svg +1 -0
  21. package/public/icons/svg/globe.svg +1 -0
  22. package/public/icons/svg/instagram.svg +1 -0
  23. package/public/icons/svg/layers.svg +1 -0
  24. package/public/icons/svg/link.svg +1 -0
  25. package/public/icons/svg/linkedin.svg +1 -0
  26. package/public/icons/svg/mail.svg +1 -0
  27. package/public/icons/svg/product-hunt.svg +1 -0
  28. package/public/icons/svg/reddit.svg +1 -0
  29. package/public/icons/svg/rocket.svg +1 -0
  30. package/public/icons/svg/search.svg +1 -0
  31. package/public/icons/svg/share.svg +1 -0
  32. package/public/icons/svg/star.svg +1 -0
  33. package/public/icons/svg/tag.svg +1 -0
  34. package/public/icons/svg/telegram.svg +1 -0
  35. package/public/icons/svg/terminal.svg +1 -0
  36. package/public/icons/svg/tiktok.svg +1 -0
  37. package/public/icons/svg/trophy.svg +1 -0
  38. package/public/icons/svg/user.svg +1 -0
  39. package/public/icons/svg/x.svg +1 -0
  40. package/public/icons/svg/youtube.svg +1 -0
  41. package/public/install.sh +44 -0
  42. package/public/manifest.webmanifest +20 -0
  43. package/public/moshpit-root-ca.crt +11 -0
  44. package/public/style.css +162 -0
  45. package/public/sw.js +30 -0
  46. package/src/auth.ts +96 -0
  47. package/src/cli.ts +203 -0
  48. package/src/client.ts +115 -0
  49. package/src/db.ts +412 -0
  50. package/src/fetch.ts +708 -0
  51. package/src/html.ts +525 -0
  52. package/src/icons.ts +56 -0
  53. package/src/mcp.ts +79 -0
  54. package/src/media.ts +160 -0
  55. package/src/ring.ts +86 -0
  56. package/src/server.ts +655 -0
  57. package/src/service.ts +369 -0
  58. package/src/tui.ts +127 -0
  59. package/src/version.ts +1 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `builtindie mcp`: built.indie for an agent, on stdio.
3
+ *
4
+ * claude mcp add builtindie -- bunx @profullstack/builtindie mcp
5
+ *
6
+ * Reading, launching and voting need nothing. Claiming and editing need
7
+ * BUILTINDIE_KEY: an account API key from built.indie.pit.world/me.
8
+ */
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
11
+ import { z } from 'zod'
12
+ import { BuiltIndieClient } from './client.js'
13
+ import { VERSION } from './version.js'
14
+
15
+ const text = (value: unknown) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] })
16
+ const failure = (error: unknown) => ({ isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }] })
17
+ const slug = z.string().min(1).max(60).describe('A product slug, e.g. linear (built.indie/p/linear)')
18
+ const wrap = <A,>(fn: (a: A) => Promise<unknown>) => async (a: A) => {
19
+ try {
20
+ return text(await fn(a))
21
+ } catch (e) {
22
+ return failure(e)
23
+ }
24
+ }
25
+
26
+ export function createMcpServer(client = new BuiltIndieClient()): McpServer {
27
+ const server: any = new McpServer({ name: 'built.indie', version: VERSION })
28
+ server.tool(
29
+ 'launch_product',
30
+ 'Launch a product on built.indie from its URL. We read the homepage (meta tags, Open Graph, JSON-LD, llms.txt, opensite.json, openprofile.md), save the logo and screenshots and put it on today\'s board. Free. To edit it later, the domain owner claims it (claim_product).',
31
+ {
32
+ url: z.string().min(3).describe('The product homepage, e.g. yourproduct.com'),
33
+ maker_name: z.string().max(60).optional(),
34
+ maker_profile: z.string().optional().describe("The maker's OpenProfile.md URL (logicsrc.com/openprofile)"),
35
+ tagline: z.string().max(90).optional().describe('Override the tagline we find'),
36
+ publish: z.boolean().optional().describe('Go live now (default true); false leaves a draft to check'),
37
+ },
38
+ wrap((a: { url: string; maker_name?: string; maker_profile?: string; tagline?: string; publish?: boolean }) => client.launch(a.url, { makerName: a.maker_name, makerProfile: a.maker_profile, tagline: a.tagline, publish: a.publish })),
39
+ )
40
+ server.tool('preview_site', 'Read any site the way built.indie does (name, tagline, description, logo, screenshots, videos, socials, llms.txt, OpenProfile) without saving anything.', { url: z.string().min(3) }, wrap((a: { url: string }) => client.preview(a.url)))
41
+ server.tool(
42
+ 'list_products',
43
+ 'Products launched on built.indie, best first: today, week, month, all, new (newest first) or featured.',
44
+ { window: z.enum(['today', 'week', 'month', 'all', 'new', 'featured']).default('today'), limit: z.number().int().min(1).max(100).default(20) },
45
+ wrap((a: { window: string; limit: number }) => client.list(a.window, a.limit)),
46
+ )
47
+ server.tool('get_product', 'Everything about one launch: description, pictures, videos, socials, maker, votes, award.', { slug }, wrap((a: { slug: string }) => client.product(a.slug)))
48
+ server.tool('search', 'Search launches by name, tagline, domain or tag.', { query: z.string().min(1).max(60) }, wrap((a: { query: string }) => client.search(a.query)))
49
+ server.tool('upvote', 'Upvote a product (one vote per visitor).', { slug }, wrap((a: { slug: string }) => client.vote(a.slug)))
50
+ server.tool(
51
+ 'edit_product',
52
+ 'Change a launch you claimed or submitted (needs BUILTINDIE_KEY). Any field left out stays as it is. refetch reads the homepage again; publish puts a draft live.',
53
+ {
54
+ slug,
55
+ name: z.string().max(60).optional(),
56
+ tagline: z.string().max(90).optional(),
57
+ description: z.string().max(1200).optional(),
58
+ tags: z.array(z.string()).max(6).optional(),
59
+ videos: z.array(z.string()).max(4).optional().describe('YouTube, Vimeo, Loom or .mp4 links'),
60
+ action: z.enum(['save', 'refetch', 'publish']).default('save'),
61
+ },
62
+ wrap(async (a: { slug: string; action: string; name?: string; tagline?: string; description?: string; tags?: string[]; videos?: string[] }) => {
63
+ if (a.action !== 'save') return client.act(a.slug, a.action as 'publish' | 'refetch')
64
+ return client.edit(a.slug, { name: a.name, tagline: a.tagline, description: a.description, tags: a.tags, videos: a.videos })
65
+ }),
66
+ )
67
+ server.tool(
68
+ 'claim_product',
69
+ 'Claim a listing for its domain owner (needs BUILTINDIE_KEY). Returns the DNS TXT record to add, plus `markdown`: step-by-step instructions for whoever has DNS access. Call again once it is in DNS and claimed comes back true.',
70
+ { slug },
71
+ wrap((a: { slug: string }) => client.claim(a.slug)),
72
+ )
73
+ server.tool('my_products', 'Your launches and claims (needs BUILTINDIE_KEY).', {}, wrap(() => client.me()))
74
+ return server
75
+ }
76
+
77
+ export async function runMcp(client = new BuiltIndieClient()): Promise<void> {
78
+ await createMcpServer(client).connect(new StdioServerTransport())
79
+ }
package/src/media.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Pictures we keep: a product's logo, its card image, its screenshots and one
3
+ * screenshot of the homepage we take ourselves. Hot-linking breaks the day a
4
+ * maker redesigns, and our CSP allows images only from ourselves, so every
5
+ * picture is downloaded once, checked by its first bytes, and served from
6
+ * /m/<product>/<file>.
7
+ *
8
+ * The homepage screenshot is headless Chrome. When we run as root it runs as
9
+ * `nobody`, with its own throwaway profile, so a hostile page renders with no
10
+ * privileges; Chrome's Local Network Access keeps the page off our loopback.
11
+ */
12
+ import { spawn } from 'node:child_process'
13
+ import { existsSync } from 'node:fs'
14
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { normalizeUrl, safeFetch, type FetchOptions } from './fetch.js'
18
+
19
+ export const IMAGE_TYPES: Record<string, string> = {
20
+ png: 'image/png',
21
+ jpg: 'image/jpeg',
22
+ gif: 'image/gif',
23
+ webp: 'image/webp',
24
+ avif: 'image/avif',
25
+ ico: 'image/x-icon',
26
+ svg: 'image/svg+xml',
27
+ }
28
+
29
+ /** The image kind by its first bytes, or null when it is not a picture we serve. */
30
+ export function sniff(b: Uint8Array): string | null {
31
+ const at = (i: number, ...v: number[]) => v.every((x, j) => b[i + j] === x)
32
+ if (at(0, 0x89, 0x50, 0x4e, 0x47)) return 'png'
33
+ if (at(0, 0xff, 0xd8, 0xff)) return 'jpg'
34
+ if (at(0, 0x47, 0x49, 0x46, 0x38)) return 'gif'
35
+ if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return 'webp'
36
+ if (at(4, 0x66, 0x74, 0x79, 0x70) && /^(avif|avis)/.test(String.fromCharCode(...b.slice(8, 12)))) return 'avif'
37
+ if (at(0, 0x00, 0x00, 0x01, 0x00)) return 'ico'
38
+ const head = new TextDecoder().decode(b.slice(0, 512)).trimStart().toLowerCase()
39
+ if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) return 'svg'
40
+ return null
41
+ }
42
+
43
+ /** Pixel size of a PNG, JPEG, GIF or WebP from its header; null when we cannot tell. */
44
+ export function dimensions(b: Uint8Array, kind: string): { width: number; height: number } | null {
45
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength)
46
+ try {
47
+ if (kind === 'png') return { width: v.getUint32(16), height: v.getUint32(20) }
48
+ if (kind === 'gif') return { width: v.getUint16(6, true), height: v.getUint16(8, true) }
49
+ if (kind === 'webp') {
50
+ const chunk = String.fromCharCode(...b.slice(12, 16))
51
+ if (chunk === 'VP8 ') return { width: v.getUint16(26, true) & 0x3fff, height: v.getUint16(28, true) & 0x3fff }
52
+ if (chunk === 'VP8L') {
53
+ const bits = v.getUint32(21, true)
54
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }
55
+ }
56
+ if (chunk === 'VP8X') return { width: 1 + (b[24]! | (b[25]! << 8) | (b[26]! << 16)), height: 1 + (b[27]! | (b[28]! << 8) | (b[29]! << 16)) }
57
+ }
58
+ if (kind === 'jpg') {
59
+ let i = 2
60
+ while (i < b.length - 9) {
61
+ if (b[i] !== 0xff) return null
62
+ const marker = b[i + 1]!
63
+ const len = v.getUint16(i + 2)
64
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { width: v.getUint16(i + 7), height: v.getUint16(i + 5) }
65
+ i += 2 + len
66
+ }
67
+ }
68
+ } catch {}
69
+ return null
70
+ }
71
+
72
+ export type Saved = { file: string; width: number | null; height: number | null }
73
+
74
+ export class Media {
75
+ constructor(readonly dir: string) {}
76
+
77
+ path(productId: string, file: string): string {
78
+ return join(this.dir, productId, file)
79
+ }
80
+
81
+ /** Download one picture into the product's folder. Null when it is missing, too big or not an image. */
82
+ async save(productId: string, name: string, url: string | null, o: FetchOptions & { minSide?: number } = {}): Promise<Saved | null> {
83
+ if (!url) return null
84
+ try {
85
+ const r = await safeFetch(url, { ...o, accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8', maxBytes: 6 * 1024 * 1024, timeoutMs: 10_000 })
86
+ if (r.status >= 300) return null
87
+ const kind = sniff(r.body)
88
+ if (!kind) return null
89
+ const size = dimensions(r.body, kind)
90
+ if (o.minSide && size && Math.min(size.width, size.height) < o.minSide) return null
91
+ return await this.write(productId, `${name}.${kind}`, r.body, size)
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ async write(productId: string, file: string, body: Uint8Array, size: { width: number; height: number } | null = null): Promise<Saved> {
98
+ await mkdir(join(this.dir, productId), { recursive: true })
99
+ await writeFile(this.path(productId, file), body)
100
+ return { file, width: size?.width ?? null, height: size?.height ?? null }
101
+ }
102
+
103
+ async read(productId: string, file: string): Promise<Uint8Array | null> {
104
+ if (!/^[A-Za-z0-9_-]{6,40}$/.test(productId) || !/^[a-z0-9-]{1,40}\.(png|jpg|gif|webp|avif|ico|svg)$/.test(file)) return null
105
+ try {
106
+ return new Uint8Array(await readFile(this.path(productId, file)))
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ async remove(productId: string): Promise<void> {
113
+ if (/^[A-Za-z0-9_-]{6,40}$/.test(productId)) await rm(join(this.dir, productId), { recursive: true, force: true })
114
+ }
115
+ }
116
+
117
+ const CHROMES = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
118
+
119
+ export function findChrome(): string | null {
120
+ const own = process.env.BUILTINDIE_CHROME
121
+ if (own === 'off') return null
122
+ if (own) return own
123
+ return CHROMES.find((c) => existsSync(c)) ?? null
124
+ }
125
+
126
+ let queue: Promise<unknown> = Promise.resolve()
127
+
128
+ /** A 1280×800 PNG of the page, or null. One at a time: Chrome is the heaviest thing we run. */
129
+ export function screenshot(url: string, chrome = findChrome(), timeoutMs = 35_000): Promise<Uint8Array | null> {
130
+ if (!chrome) return Promise.resolve(null)
131
+ const target = normalizeUrl(url).href
132
+ const run = async (): Promise<Uint8Array | null> => {
133
+ const dir = await mkdtemp(join(tmpdir(), 'builtindie-shot-'))
134
+ try {
135
+ const root = process.getuid?.() === 0
136
+ if (root) await chmod(dir, 0o777)
137
+ const args = [
138
+ '--headless=new', '--disable-gpu', '--hide-scrollbars', '--mute-audio', '--no-first-run', '--no-default-browser-check',
139
+ '--disable-extensions', '--disable-sync', '--disable-background-networking', '--deny-permission-prompts',
140
+ `--user-data-dir=${join(dir, 'profile')}`, '--window-size=1280,800', '--virtual-time-budget=6000', `--screenshot=${join(dir, 'shot.png')}`, target,
141
+ ]
142
+ // runuser lives in /usr/sbin, which a service's PATH often leaves out.
143
+ const runuser = ['/usr/sbin/runuser', '/sbin/runuser', '/usr/bin/runuser'].find((p) => existsSync(p)) ?? 'runuser'
144
+ const [cmd, argv] = root ? [runuser, ['-u', 'nobody', '--', '/usr/bin/env', `HOME=${dir}`, chrome, ...args]] : [chrome, args]
145
+ await new Promise<void>((resolve) => {
146
+ const child = spawn(cmd, argv as string[], { stdio: 'ignore' })
147
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
148
+ child.on('exit', () => (clearTimeout(timer), resolve()))
149
+ child.on('error', () => (clearTimeout(timer), resolve()))
150
+ })
151
+ const png = new Uint8Array(await readFile(join(dir, 'shot.png')).catch(() => Buffer.alloc(0)))
152
+ return sniff(png) === 'png' ? png : null
153
+ } finally {
154
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
155
+ }
156
+ }
157
+ const next = queue.then(run, run)
158
+ queue = next.catch(() => null)
159
+ return next
160
+ }
package/src/ring.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The Moshpit ring bar: the thin strip across the top of every page that links
3
+ * this site to the others on the ring (https://pit.world/ring).
4
+ *
5
+ * pit.world owns the member list. We read https://pit.world/api/ring every ten
6
+ * minutes in the background and draw the bar ourselves, so a page never waits
7
+ * on pit.world and needs no script or CSP exception; until the first read (or
8
+ * if pit.world is down) the list below is used.
9
+ *
10
+ * A Moshpit name opens only for a visitor on Moshpit DNS. Everyone else reaches
11
+ * it through pit.world's relay, https://<name>.pit.world/, which (like the
12
+ * registry gateway) tells us so with an x-moshpit-name header. So: a request for
13
+ * our own name without that header came from Moshpit DNS and gets direct links;
14
+ * anything else gets relay links.
15
+ *
16
+ * Same file in seo.rank, family.ancestry and pay.agent; pit.world has the original in src/ring.ts.
17
+ */
18
+ const RING_HOME = 'https://pit.world/ring'
19
+ const RING_API = 'https://pit.world/api/ring'
20
+ const relayUrl = (name: string) => `https://${name}.pit.world/`
21
+ export const RING_PLACEHOLDER = '<!--ring-bar-->'
22
+
23
+ export type RingSite = { name: string; about: string; moshpit: boolean }
24
+
25
+ let sites: RingSite[] = [
26
+ { name: 'pit.world', about: 'Private search, and the front door of the Moshpit network.', moshpit: false },
27
+ { name: 'seo.rank', about: 'Free, open-source on-page SEO audit. API, CLI and MCP.', moshpit: true },
28
+ { name: 'family.ancestry', about: 'Your family tree, on your own name. Import GEDCOM, share by link.', moshpit: true },
29
+ ]
30
+
31
+ const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
32
+ const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]!)
33
+
34
+ export function ringSites(): RingSite[] {
35
+ return sites
36
+ }
37
+
38
+ /** Replace the member list from an /api/ring document; a malformed one is ignored. */
39
+ export function setRing(doc: unknown): boolean {
40
+ const list = (doc as { sites?: unknown })?.sites
41
+ if (!Array.isArray(list)) return false
42
+ const next = list
43
+ .filter((s) => s && typeof s.name === 'string' && /^[a-z0-9.-]{1,253}$/.test(s.name))
44
+ .map((s) => ({ name: s.name as string, about: String(s.about ?? '').slice(0, 200), moshpit: s.moshpit === true }))
45
+ if (!next.length) return false
46
+ sites = next
47
+ return true
48
+ }
49
+
50
+ /** Refresh from pit.world now and every `everyMs`. Failures keep the last good list. */
51
+ export function startRingRefresh(fetchImpl: typeof fetch = fetch, everyMs = 10 * 60_000): () => void {
52
+ const refresh = () =>
53
+ fetchImpl(RING_API, { signal: AbortSignal.timeout(5000) })
54
+ .then((r) => (r.ok ? r.json() : null))
55
+ .then((doc) => doc && setRing(doc))
56
+ .catch(() => {})
57
+ refresh()
58
+ const timer = setInterval(refresh, everyMs)
59
+ timer.unref?.()
60
+ return () => clearInterval(timer)
61
+ }
62
+
63
+ /** True when the request reached us by our own Moshpit name, not through the gateway. */
64
+ export function visitorOnMoshpit(self: string, host: string | undefined, gatewayHeader: string | undefined): boolean {
65
+ return !gatewayHeader && String(host ?? '').toLowerCase().replace(/:\d+$/, '') === self
66
+ }
67
+
68
+ export function siteHref(site: RingSite, direct: boolean): string {
69
+ return site.moshpit && !direct ? relayUrl(site.name) : `https://${site.name}/`
70
+ }
71
+
72
+ export function ringBar(self: string, direct: boolean): string {
73
+ const at = sites.findIndex((s) => s.name === self)
74
+ const i = at < 0 ? 0 : at
75
+ const prev = sites[(i - 1 + sites.length) % sites.length]!
76
+ const next = sites[(i + 1) % sites.length]!
77
+ const link = (s: RingSite, text = s.name) =>
78
+ `<a href="${esc(siteHref(s, direct))}"${s.name === self ? ' aria-current="page"' : ''} title="${esc(s.about)}">${esc(text)}</a>`
79
+ return `<nav class="ring-bar" aria-label="Moshpit ring">
80
+ <a class="ring-home" href="${RING_HOME}">◉ <span>Moshpit </span>ring</a>
81
+ ${link(prev, `‹ ${prev.name}`)}
82
+ <span class="ring-sites">${sites.map((s) => link(s)).join('')}</span>
83
+ ${link(next, `${next.name} ›`)}
84
+ <a href="${RING_HOME}/random?from=${encodeURIComponent(self)}${direct ? '&amp;direct=1' : ''}">random</a>
85
+ </nav>`
86
+ }