@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.
- package/LICENSE +21 -0
- package/OpenStack.md +74 -0
- package/README.md +63 -2
- package/bin/builtindie +3 -0
- package/package.json +35 -4
- package/public/app.js +35 -0
- package/public/favicon.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/openicon.json +574 -0
- package/public/icons/svg/apple.svg +1 -0
- package/public/icons/svg/arrow-up.svg +1 -0
- package/public/icons/svg/bluesky.svg +1 -0
- package/public/icons/svg/check.svg +1 -0
- package/public/icons/svg/chrome.svg +1 -0
- package/public/icons/svg/code.svg +1 -0
- package/public/icons/svg/discord.svg +1 -0
- package/public/icons/svg/dollar.svg +1 -0
- package/public/icons/svg/external-link.svg +1 -0
- package/public/icons/svg/github.svg +1 -0
- package/public/icons/svg/globe.svg +1 -0
- package/public/icons/svg/instagram.svg +1 -0
- package/public/icons/svg/layers.svg +1 -0
- package/public/icons/svg/link.svg +1 -0
- package/public/icons/svg/linkedin.svg +1 -0
- package/public/icons/svg/mail.svg +1 -0
- package/public/icons/svg/product-hunt.svg +1 -0
- package/public/icons/svg/reddit.svg +1 -0
- package/public/icons/svg/rocket.svg +1 -0
- package/public/icons/svg/search.svg +1 -0
- package/public/icons/svg/share.svg +1 -0
- package/public/icons/svg/star.svg +1 -0
- package/public/icons/svg/tag.svg +1 -0
- package/public/icons/svg/telegram.svg +1 -0
- package/public/icons/svg/terminal.svg +1 -0
- package/public/icons/svg/tiktok.svg +1 -0
- package/public/icons/svg/trophy.svg +1 -0
- package/public/icons/svg/user.svg +1 -0
- package/public/icons/svg/x.svg +1 -0
- package/public/icons/svg/youtube.svg +1 -0
- package/public/install.sh +44 -0
- package/public/manifest.webmanifest +20 -0
- package/public/moshpit-root-ca.crt +11 -0
- package/public/style.css +162 -0
- package/public/sw.js +30 -0
- package/src/auth.ts +96 -0
- package/src/cli.ts +203 -0
- package/src/client.ts +115 -0
- package/src/db.ts +412 -0
- package/src/fetch.ts +708 -0
- package/src/html.ts +525 -0
- package/src/icons.ts +56 -0
- package/src/mcp.ts +79 -0
- package/src/media.ts +160 -0
- package/src/ring.ts +86 -0
- package/src/server.ts +655 -0
- package/src/service.ts +369 -0
- package/src/tui.ts +127 -0
- package/src/version.ts +1 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What built.indie does, once, for every surface: the web pages, the JSON API,
|
|
3
|
+
* the CLI and the MCP server all end up here.
|
|
4
|
+
*
|
|
5
|
+
* submit URL in, a listing out: everything fetched, pictures saved. Free.
|
|
6
|
+
* Anonymous launches go live at once; a signed-in submitter checks a draft first.
|
|
7
|
+
* publish the draft goes live on today's board
|
|
8
|
+
* claim the domain's owner proves it with a DNS TXT record and takes the listing
|
|
9
|
+
* refetch read the homepage again (the maker changed their logo)
|
|
10
|
+
* verify the maker put our badge on their site: a followed link back
|
|
11
|
+
* feature pay in crypto (CoinPay) to sit on top for a week
|
|
12
|
+
*
|
|
13
|
+
* Products are described in the LogicSRC open specs: each one is an OpenSite
|
|
14
|
+
* record (logicsrc.com/docs/opensite) and an OpenProfile.md with Kind
|
|
15
|
+
* organization (logicsrc.com/docs/openprofile); a maker is an OpenProfile.md
|
|
16
|
+
* of their own, which we read and link to.
|
|
17
|
+
*/
|
|
18
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
19
|
+
import { Resolver } from 'node:dns/promises'
|
|
20
|
+
import { keyedSection, listSection, makeOpenProfile, renderOpenProfile } from '@profullstack/openprofile'
|
|
21
|
+
import { autofetch, clip, FetchError, normalizeUrl, readProfile, safeFetch, SOCIAL_KINDS, videoFrom, type FetchOptions, type SiteInfo, type Video } from './fetch.js'
|
|
22
|
+
import { Media, screenshot } from './media.js'
|
|
23
|
+
import { dayOf, makerIdFor, newId, Store, type Product, type User } from './db.js'
|
|
24
|
+
|
|
25
|
+
export class InputError extends Error {
|
|
26
|
+
constructor(message: string, readonly field?: string) {
|
|
27
|
+
super(message)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CoinPayConfig = { baseUrl: string; apiKey: string; businessId: string; webhookSecret: string }
|
|
32
|
+
export type Config = {
|
|
33
|
+
origin: string
|
|
34
|
+
coinpay: CoinPayConfig | null
|
|
35
|
+
featureUsd: number
|
|
36
|
+
featureDays: number
|
|
37
|
+
chains: string[]
|
|
38
|
+
fetch?: FetchOptions
|
|
39
|
+
screenshots?: boolean
|
|
40
|
+
/** Secret behind each person's claim token. */
|
|
41
|
+
claimSecret?: string
|
|
42
|
+
/** TXT lookups; tests pass their own. */
|
|
43
|
+
resolveTxt?: (name: string) => Promise<string[][]>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The record an owner adds: `_built-indie.<domain> TXT "built-indie-verify=<token>"` (or the same on the domain itself). */
|
|
47
|
+
export const CLAIM_LABEL = '_built-indie'
|
|
48
|
+
export const CLAIM_PREFIX = 'built-indie-verify='
|
|
49
|
+
|
|
50
|
+
const resolver = new Resolver({ timeout: 4000, tries: 2 })
|
|
51
|
+
resolver.setServers(['1.1.1.1', '9.9.9.9'])
|
|
52
|
+
const publicTxt = (name: string) => resolver.resolveTxt(name)
|
|
53
|
+
|
|
54
|
+
export const CHAIN_LABELS: Record<string, string> = {
|
|
55
|
+
USDC_POL: 'USDC on Polygon', USDC_SOL: 'USDC on Solana', USDC_ETH: 'USDC on Ethereum', USDT_POL: 'USDT on Polygon',
|
|
56
|
+
BTC: 'Bitcoin', ETH: 'Ether', SOL: 'Solana', POL: 'Polygon', DOGE: 'Dogecoin', BCH: 'Bitcoin Cash', XRP: 'XRP', ADA: 'Cardano', BNB: 'BNB',
|
|
57
|
+
}
|
|
58
|
+
export const DEFAULT_CHAINS = Object.keys(CHAIN_LABELS)
|
|
59
|
+
export const SETTLED = new Set(['paid', 'completed', 'confirmed', 'forwarded', 'succeeded', 'settled'])
|
|
60
|
+
|
|
61
|
+
export type Edit = Partial<{ name: string; tagline: string; description: string; tags: string[] | string; makerName: string; makerProfile: string; videos: string[] | string; socials: Record<string, string>; remove: string[] | string }>
|
|
62
|
+
|
|
63
|
+
const list = (v: string[] | string | undefined) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map((s) => s.trim()).filter(Boolean)
|
|
64
|
+
|
|
65
|
+
export class BuiltIndie {
|
|
66
|
+
constructor(readonly store: Store, readonly media: Media, readonly config: Config) {}
|
|
67
|
+
|
|
68
|
+
/** A product page's public address. */
|
|
69
|
+
link(p: Pick<Product, 'slug'>): string {
|
|
70
|
+
return `${this.config.origin}/p/${p.slug}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read the URL and make a draft. A URL already live comes back as `existing` instead. */
|
|
74
|
+
async submit(input: { url: string; makerName?: string; makerProfile?: string }, submitter: string, user: User | null = null): Promise<{ product: Product; existing: boolean }> {
|
|
75
|
+
let url: URL
|
|
76
|
+
try {
|
|
77
|
+
url = normalizeUrl(input.url)
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new InputError((e as Error).message, 'url')
|
|
80
|
+
}
|
|
81
|
+
const already = this.store.byUrl(url.href)
|
|
82
|
+
if (already?.status === 'live') return { product: already, existing: true }
|
|
83
|
+
if (this.store.submissionsFrom(submitter, Date.now() - 3600_000) >= 10) throw new InputError('That is a lot of launches in an hour. Try again later.')
|
|
84
|
+
const info = await this.read(url.href)
|
|
85
|
+
const id = newId()
|
|
86
|
+
const pictures = await this.pictures(id, info)
|
|
87
|
+
const product = this.store.create(
|
|
88
|
+
{
|
|
89
|
+
id,
|
|
90
|
+
slug: this.store.freeSlug(info.name),
|
|
91
|
+
url: info.url,
|
|
92
|
+
domain: info.domain,
|
|
93
|
+
name: info.name,
|
|
94
|
+
tagline: info.tagline,
|
|
95
|
+
description: info.description,
|
|
96
|
+
...pictures,
|
|
97
|
+
videos: info.videos,
|
|
98
|
+
socials: info.socials,
|
|
99
|
+
tags: info.tags,
|
|
100
|
+
themeColor: info.themeColor,
|
|
101
|
+
llms: info.llms,
|
|
102
|
+
profile: info.profile,
|
|
103
|
+
record: this.recordFrom(info),
|
|
104
|
+
extras: { stack: info.stack, pricing: info.pricing, openicon: info.openicon },
|
|
105
|
+
makerName: clip(input.makerName ?? '', 60),
|
|
106
|
+
makerProfile: null,
|
|
107
|
+
submitterUser: user?.id ?? null,
|
|
108
|
+
status: user ? 'draft' : 'live',
|
|
109
|
+
},
|
|
110
|
+
submitter,
|
|
111
|
+
)
|
|
112
|
+
if (input.makerProfile?.trim()) await this.setMaker(product, input.makerProfile, input.makerName).catch(() => {})
|
|
113
|
+
return { product: this.store.product(id)!, existing: false }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The TXT value this person puts in DNS to claim this product. Stable, so a record added yesterday still counts. */
|
|
117
|
+
claimToken(p: Product, user: User): string {
|
|
118
|
+
return createHmac('sha256', this.config.claimSecret ?? 'built.indie').update(`claim:${user.id}:${p.domain}`).digest('base64url').slice(0, 24)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Look for the claim record at _built-indie.<domain>, then on the domain itself (and its apex for www). */
|
|
122
|
+
async claim(p: Product, user: User): Promise<{ ok: boolean; looked: string[] }> {
|
|
123
|
+
if (p.status === 'hidden') throw new InputError('This product was taken down.')
|
|
124
|
+
if (p.ownerId === user.id) return { ok: true, looked: [] }
|
|
125
|
+
const want = `${CLAIM_PREFIX}${this.claimToken(p, user)}`
|
|
126
|
+
const bare = p.domain.replace(/^www\./, '')
|
|
127
|
+
const names = [...new Set([`${CLAIM_LABEL}.${bare}`, bare, p.domain])]
|
|
128
|
+
const lookup = this.config.resolveTxt ?? publicTxt
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
const records = await lookup(name).catch(() => [] as string[][])
|
|
131
|
+
if (records.some((chunks) => chunks.join('').trim() === want)) {
|
|
132
|
+
this.store.claim(p.id, user.id)
|
|
133
|
+
return { ok: true, looked: names }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { ok: false, looked: names }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async read(url: string): Promise<SiteInfo> {
|
|
140
|
+
try {
|
|
141
|
+
return await autofetch(url, this.config.fetch)
|
|
142
|
+
} catch (e) {
|
|
143
|
+
throw new InputError(e instanceof FetchError ? e.message : `Could not read that site: ${e instanceof Error ? e.message : e}`, 'url')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Logo, card image, up to four screenshots from the page, and our own screenshot of the homepage. */
|
|
148
|
+
private async pictures(id: string, info: SiteInfo) {
|
|
149
|
+
const o = this.config.fetch ?? {}
|
|
150
|
+
const [logo, og, shot, ...found] = await Promise.all([
|
|
151
|
+
this.media.save(id, 'logo', info.logo, o),
|
|
152
|
+
this.media.save(id, 'card', info.ogImage, o),
|
|
153
|
+
this.config.screenshots === false ? null : screenshot(info.url).then((png) => (png ? this.media.write(id, 'home.png', png, { width: 1280, height: 800 }) : null)),
|
|
154
|
+
...info.images.slice(0, 6).map((src, i) => this.media.save(id, `shot-${i + 1}`, src, { ...o, minSide: 200 })),
|
|
155
|
+
])
|
|
156
|
+
return {
|
|
157
|
+
logo: logo?.file ?? null,
|
|
158
|
+
ogImage: og?.file ?? null,
|
|
159
|
+
screenshot: shot?.file ?? null,
|
|
160
|
+
images: found.filter((f): f is NonNullable<typeof f> => Boolean(f)).slice(0, 4).map((f) => f.file),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
publish(p: Product): Product {
|
|
165
|
+
if (p.status === 'hidden') throw new InputError('This product was taken down.')
|
|
166
|
+
if (!p.name.trim()) throw new InputError('Give it a name', 'name')
|
|
167
|
+
return this.store.update(p.id, { status: 'live' })!
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async refetch(p: Product): Promise<Product> {
|
|
171
|
+
const info = await this.read(p.url)
|
|
172
|
+
await this.media.remove(p.id)
|
|
173
|
+
const pictures = await this.pictures(p.id, info)
|
|
174
|
+
return this.store.update(p.id, { ...pictures, videos: info.videos, socials: info.socials, llms: info.llms, profile: info.profile, record: this.recordFrom(info), extras: { stack: info.stack, pricing: info.pricing, openicon: info.openicon }, themeColor: info.themeColor, ...(info.tags.length && !p.tags.length ? { tags: info.tags } : {}) })!
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async edit(p: Product, e: Edit): Promise<Product> {
|
|
178
|
+
const patch: Parameters<Store['update']>[1] = {}
|
|
179
|
+
if (e.name !== undefined) {
|
|
180
|
+
if (!e.name.trim()) throw new InputError('Give it a name', 'name')
|
|
181
|
+
patch.name = clip(e.name, 60)
|
|
182
|
+
}
|
|
183
|
+
if (e.tagline !== undefined) patch.tagline = clip(e.tagline, 90)
|
|
184
|
+
if (e.description !== undefined) patch.description = clip(e.description, 1200)
|
|
185
|
+
if (e.tags !== undefined) patch.tags = [...new Set(list(e.tags).map((t) => t.toLowerCase().replace(/^#/, '').replace(/[^a-z0-9 +#.-]/g, '').slice(0, 24)).filter((t) => t.length >= 2))].slice(0, 6)
|
|
186
|
+
if (e.makerName !== undefined) patch.makerName = clip(e.makerName, 60)
|
|
187
|
+
if (e.videos !== undefined) {
|
|
188
|
+
const videos: Video[] = []
|
|
189
|
+
for (const v of list(e.videos)) {
|
|
190
|
+
const video = videoFrom(v.startsWith('http') ? v : `https://${v}`)
|
|
191
|
+
if (!video) throw new InputError(`Not a YouTube, Vimeo, Loom or .mp4 link: ${v}`, 'videos')
|
|
192
|
+
if (!videos.some((x) => x.url === video.url)) videos.push(video)
|
|
193
|
+
}
|
|
194
|
+
patch.videos = videos.slice(0, 4)
|
|
195
|
+
}
|
|
196
|
+
if (e.socials !== undefined) {
|
|
197
|
+
const socials: Record<string, string> = {}
|
|
198
|
+
for (const [k, v] of Object.entries(e.socials)) if (SOCIAL_KINDS.includes(k) && /^https:\/\/\S+$/.test(String(v ?? '').trim())) socials[k] = String(v).trim().slice(0, 300)
|
|
199
|
+
patch.socials = socials
|
|
200
|
+
}
|
|
201
|
+
if (e.remove !== undefined) {
|
|
202
|
+
const drop = new Set(list(e.remove))
|
|
203
|
+
if (p.logo && drop.has(p.logo)) patch.logo = null
|
|
204
|
+
if (p.ogImage && drop.has(p.ogImage)) patch.ogImage = null
|
|
205
|
+
if (p.screenshot && drop.has(p.screenshot)) patch.screenshot = null
|
|
206
|
+
patch.images = p.images.filter((f) => !drop.has(f))
|
|
207
|
+
}
|
|
208
|
+
let updated = this.store.update(p.id, patch)!
|
|
209
|
+
if (e.makerProfile !== undefined) updated = e.makerProfile.trim() ? await this.setMaker(updated, e.makerProfile, e.makerName) : this.store.update(p.id, { makerProfile: null, makerId: null, makerVerified: false })!
|
|
210
|
+
return updated
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The maker is an OpenProfile.md. We read it; it is verified when it names
|
|
215
|
+
* the product (Web or an account on the product's domain) or lives there.
|
|
216
|
+
*/
|
|
217
|
+
async setMaker(p: Product, profileUrl: string, name?: string): Promise<Product> {
|
|
218
|
+
let url: URL
|
|
219
|
+
try {
|
|
220
|
+
url = normalizeUrl(profileUrl)
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new InputError(`Maker profile: ${(e as Error).message}`, 'makerProfile')
|
|
223
|
+
}
|
|
224
|
+
const r = await safeFetch(url, { ...this.config.fetch, accept: 'text/markdown,text/plain;q=0.9', maxBytes: 256 * 1024, timeoutMs: 8000 }).catch((e) => {
|
|
225
|
+
throw new InputError(`Could not read the maker profile: ${(e as Error).message}`, 'makerProfile')
|
|
226
|
+
})
|
|
227
|
+
const profile = r.status < 300 ? readProfile(new TextDecoder().decode(r.body), r.url.href) : null
|
|
228
|
+
if (!profile) throw new InputError('That is not an OpenProfile.md (logicsrc.com/openprofile): a Markdown file starting with "# Your Name".', 'makerProfile')
|
|
229
|
+
const host = (u: string | null) => {
|
|
230
|
+
try {
|
|
231
|
+
return u ? new URL(u).hostname.replace(/^www\./, '') : ''
|
|
232
|
+
} catch {
|
|
233
|
+
return ''
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const verified = host(r.url.href) === p.domain || host(profile.web) === p.domain || profile.accounts.some((a) => host(a) === p.domain)
|
|
237
|
+
const maker = this.store.upsertMaker({ name: profile.name, profileUrl: r.url.href, headline: profile.headline, avatar: profile.avatar, web: profile.web, accounts: profile.accounts })
|
|
238
|
+
return this.store.update(p.id, { makerProfile: r.url.href, makerId: maker.id, makerVerified: verified, makerName: clip(name?.trim() || profile.name, 60) })!
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Has the maker linked back to us from their homepage? */
|
|
242
|
+
async verify(p: Product): Promise<boolean> {
|
|
243
|
+
const r = await safeFetch(p.url, { ...this.config.fetch, accept: 'text/html' }).catch(() => null)
|
|
244
|
+
const html = r && r.status < 300 ? new TextDecoder().decode(r.body) : ''
|
|
245
|
+
const ours = new URL(this.config.origin).hostname
|
|
246
|
+
const ok = new RegExp(`href=["']?https?://(${ours.replace(/\./g, '\\.')}|built\\.indie)(/|["'])`, 'i').test(html)
|
|
247
|
+
this.store.setVerified(p.id, ok ? Date.now() : null)
|
|
248
|
+
return ok
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The product as an OpenSite 0.1 record: what we read, kept so anyone can reuse it. */
|
|
252
|
+
recordFrom(info: SiteInfo): Record<string, unknown> {
|
|
253
|
+
return {
|
|
254
|
+
opensite: '0.1',
|
|
255
|
+
url: info.url,
|
|
256
|
+
canonical: info.canonical,
|
|
257
|
+
site: { name: info.name, web: new URL(info.url).origin },
|
|
258
|
+
kind: info.kind,
|
|
259
|
+
title: info.name,
|
|
260
|
+
description: info.description,
|
|
261
|
+
...(info.ogImage ? { image: { url: info.ogImage } } : {}),
|
|
262
|
+
language: info.language,
|
|
263
|
+
...(info.profile ? { author: { name: info.profile.name, profile: info.profile.url } } : {}),
|
|
264
|
+
tags: info.tags,
|
|
265
|
+
feeds: info.feeds,
|
|
266
|
+
published_at: null,
|
|
267
|
+
modified_at: null,
|
|
268
|
+
fetched_at: new Date().toISOString(),
|
|
269
|
+
status: 'live',
|
|
270
|
+
source: info.declared ? 'declared' : 'read',
|
|
271
|
+
cards: info.cards,
|
|
272
|
+
jsonld: info.jsonld,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The product as an OpenProfile.md, Kind organization. */
|
|
277
|
+
openProfile(p: Product): string {
|
|
278
|
+
const logo = p.logo ? `${this.config.origin}/m/${p.id}/${p.logo}` : null
|
|
279
|
+
const accounts = [...Object.values(p.socials), p.llms?.url, this.link(p)].filter(Boolean) as string[]
|
|
280
|
+
return renderOpenProfile(
|
|
281
|
+
makeOpenProfile({
|
|
282
|
+
name: p.name,
|
|
283
|
+
identity: { Kind: 'organization', Web: p.url, Avatar: logo, Handle: p.slug },
|
|
284
|
+
headline: p.tagline || null,
|
|
285
|
+
prose: p.description || null,
|
|
286
|
+
sections: [
|
|
287
|
+
listSection('Accounts', accounts),
|
|
288
|
+
listSection('Topics', p.tags),
|
|
289
|
+
p.makerProfile ? keyedSection('Operator', { Name: p.makerName || null, Profile: p.makerProfile }) : p.makerName ? keyedSection('Operator', { Name: p.makerName }) : null,
|
|
290
|
+
keyedSection('Launch', { Launched: p.launchedAt ? dayOf(p.launchedAt) : null, Votes: p.votes, On: this.link(p) }),
|
|
291
|
+
],
|
|
292
|
+
}),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A maker as an OpenProfile.md: their own profile's essentials plus what they launched here. */
|
|
297
|
+
makerProfile(makerId: string): string | null {
|
|
298
|
+
const m = this.store.maker(makerId)
|
|
299
|
+
if (!m) return null
|
|
300
|
+
const products = this.store.byMaker(makerId)
|
|
301
|
+
return renderOpenProfile(
|
|
302
|
+
makeOpenProfile({
|
|
303
|
+
name: m.name,
|
|
304
|
+
identity: { Kind: 'person', Web: m.web, Avatar: m.avatar, Profile: m.profileUrl },
|
|
305
|
+
headline: m.headline || null,
|
|
306
|
+
sections: [listSection('Accounts', [m.profileUrl, ...m.accounts]), listSection('Projects', products.map((p) => `[${p.name}](${this.link(p)}): ${p.tagline}`))],
|
|
307
|
+
}),
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Start a crypto payment to feature a product; returns where to send the buyer. */
|
|
312
|
+
async feature(p: Product, chain: string, fetcher: typeof fetch = fetch): Promise<{ checkoutUrl: string; ref: string }> {
|
|
313
|
+
const cp = this.config.coinpay
|
|
314
|
+
if (!cp) throw new InputError('Featuring is not set up on this server yet.')
|
|
315
|
+
if (p.status !== 'live') throw new InputError('Publish it first.')
|
|
316
|
+
if (!this.config.chains.includes(chain)) throw new InputError('Pick a currency from the list', 'chain')
|
|
317
|
+
const res = await fetcher(`${cp.baseUrl}/api/payments/create`, {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: { authorization: `Bearer ${cp.apiKey}`, 'content-type': 'application/json' },
|
|
320
|
+
body: JSON.stringify({
|
|
321
|
+
business_id: cp.businessId,
|
|
322
|
+
amount: this.config.featureUsd,
|
|
323
|
+
currency: 'USD',
|
|
324
|
+
blockchain: chain,
|
|
325
|
+
payment_method: 'crypto',
|
|
326
|
+
description: `built.indie: feature ${p.name} for ${this.config.featureDays} days`,
|
|
327
|
+
metadata: { product_id: p.id, product: 'feature' },
|
|
328
|
+
success_url: `${this.link(p)}?featured=1`,
|
|
329
|
+
cancel_url: this.link(p),
|
|
330
|
+
}),
|
|
331
|
+
signal: AbortSignal.timeout(20_000),
|
|
332
|
+
})
|
|
333
|
+
if (!res.ok) throw new Error(`CoinPay said ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
334
|
+
const body = (await res.json()) as any
|
|
335
|
+
const payment = body?.payment ?? body
|
|
336
|
+
const ref = String(payment?.id ?? payment?.payment_id ?? '')
|
|
337
|
+
if (!ref) throw new Error('CoinPay returned no payment reference')
|
|
338
|
+
// Written before the buyer leaves: a webhook can beat the redirect.
|
|
339
|
+
this.store.addPayment({ ref, productId: p.id, amountUsd: this.config.featureUsd, chain, days: this.config.featureDays })
|
|
340
|
+
return { checkoutUrl: `${cp.baseUrl}/pay/${ref}`, ref }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** CoinPay's webhook: `t=<unix>,v1=<hmac of "t.body">`; the payload is { data: { payment_id, status } }. */
|
|
344
|
+
webhook(raw: string, header: string | undefined, nowSeconds = Date.now() / 1000): { ok: boolean; status?: number } {
|
|
345
|
+
const cp = this.config.coinpay
|
|
346
|
+
if (!cp?.webhookSecret || !header) return { ok: false, status: 401 }
|
|
347
|
+
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=').map((s) => s.trim())))
|
|
348
|
+
const t = Number(parts.t)
|
|
349
|
+
if (!Number.isFinite(t) || !parts.v1 || Math.abs(nowSeconds - t) > 300) return { ok: false, status: 401 }
|
|
350
|
+
const expected = Buffer.from(createHmac('sha256', cp.webhookSecret).update(`${parts.t}.${raw}`).digest('hex'), 'hex')
|
|
351
|
+
const given = Buffer.from(String(parts.v1), 'hex')
|
|
352
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, status: 401 }
|
|
353
|
+
let payload: any
|
|
354
|
+
try {
|
|
355
|
+
payload = JSON.parse(raw)
|
|
356
|
+
} catch {
|
|
357
|
+
return { ok: false, status: 400 }
|
|
358
|
+
}
|
|
359
|
+
const data = payload?.data ?? payload
|
|
360
|
+
const ref = String(data?.payment_id ?? data?.id ?? '')
|
|
361
|
+
const status = String(data?.status ?? payload?.type?.replace(/^payment\./, '') ?? '').toLowerCase()
|
|
362
|
+
if (!ref || !this.store.payment(ref)) return { ok: true }
|
|
363
|
+
if (SETTLED.has(status)) this.store.settle(ref)
|
|
364
|
+
else if (status) this.store.setPaymentStatus(ref, status)
|
|
365
|
+
return { ok: true }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export { makerIdFor }
|
package/src/tui.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `builtindie` with no arguments: the board in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* ←/→ switch board (today, week, month, all, new), ↑/↓ pick, Enter shows it,
|
|
5
|
+
* v upvotes, l launches a URL you type, q quits. Glyphs come from the OpenIcon
|
|
6
|
+
* set: Nerd Font, Unicode or ASCII, whichever this terminal can draw.
|
|
7
|
+
*/
|
|
8
|
+
const UP = glyph('arrow-up') || '^'
|
|
9
|
+
const OK = glyph('check') || '+'
|
|
10
|
+
import { createApp } from '@profullstack/hqtui'
|
|
11
|
+
import { BuiltIndieClient, type ApiProduct } from './client.js'
|
|
12
|
+
import { glyph } from './icons.js'
|
|
13
|
+
|
|
14
|
+
export const WINDOWS = ['today', 'week', 'month', 'all', 'new'] as const
|
|
15
|
+
|
|
16
|
+
export type TuiState = {
|
|
17
|
+
window: number
|
|
18
|
+
products: ApiProduct[]
|
|
19
|
+
at: number
|
|
20
|
+
open: ApiProduct | null
|
|
21
|
+
typing: string | null
|
|
22
|
+
busy: string | null
|
|
23
|
+
flash: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const initialState = (): TuiState => ({ window: 0, products: [], at: 0, open: null, typing: null, busy: null, flash: null })
|
|
27
|
+
|
|
28
|
+
/** Pure key handling, testable without a terminal. */
|
|
29
|
+
export function onKey(s: TuiState, key: { name: string; key: string; char?: string }): 'load' | 'vote' | 'launch' | 'quit' | null {
|
|
30
|
+
if (key.key === 'ctrl+c') return 'quit'
|
|
31
|
+
if (s.typing !== null) {
|
|
32
|
+
if (key.name === 'escape') s.typing = null
|
|
33
|
+
else if (key.name === 'enter') return s.typing.trim() ? 'launch' : ((s.typing = null), null)
|
|
34
|
+
else if (key.name === 'backspace') s.typing = s.typing.slice(0, -1)
|
|
35
|
+
else if (key.char && key.char.length === 1 && !key.key.startsWith('ctrl+')) s.typing += key.char
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
if (key.name === 'escape') return s.open ? ((s.open = null), null) : 'quit'
|
|
39
|
+
if (key.char === 'q') return 'quit'
|
|
40
|
+
if (key.char === 'l') return (s.typing = ''), null
|
|
41
|
+
if (key.char === 'v') return s.products[s.at] ? 'vote' : null
|
|
42
|
+
if (key.name === 'right' || key.name === 'left') {
|
|
43
|
+
s.window = (s.window + (key.name === 'right' ? 1 : WINDOWS.length - 1)) % WINDOWS.length
|
|
44
|
+
s.at = 0
|
|
45
|
+
s.open = null
|
|
46
|
+
return 'load'
|
|
47
|
+
}
|
|
48
|
+
if (key.name === 'down') s.at = Math.min(s.at + 1, Math.max(0, s.products.length - 1))
|
|
49
|
+
if (key.name === 'up') s.at = Math.max(0, s.at - 1)
|
|
50
|
+
if (key.name === 'enter') s.open = s.products[s.at] ?? null
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runTui(client = new BuiltIndieClient()): Promise<void> {
|
|
55
|
+
const s = initialState()
|
|
56
|
+
const app = await createApp({ title: 'built.indie', quitKeys: [] })
|
|
57
|
+
const run = async (label: string, fn: () => Promise<void>) => {
|
|
58
|
+
s.busy = label
|
|
59
|
+
s.flash = null
|
|
60
|
+
app.invalidate()
|
|
61
|
+
try {
|
|
62
|
+
await fn()
|
|
63
|
+
} catch (e) {
|
|
64
|
+
s.flash = e instanceof Error ? e.message : String(e)
|
|
65
|
+
} finally {
|
|
66
|
+
s.busy = null
|
|
67
|
+
app.invalidate()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const load = () => run('Loading…', async () => void (s.products = (await client.list(WINDOWS[s.window]!, 50)).products))
|
|
71
|
+
app.on('key', (event: any) => {
|
|
72
|
+
const action = onKey(s, event)
|
|
73
|
+
if (action === 'quit') app.quit()
|
|
74
|
+
else if (action === 'load') void load()
|
|
75
|
+
else if (action === 'vote') {
|
|
76
|
+
const p = s.products[s.at]!
|
|
77
|
+
void run('Voting…', async () => {
|
|
78
|
+
const r = await client.vote(p.slug)
|
|
79
|
+
p.votes = r.votes
|
|
80
|
+
s.flash = r.counted ? `▲ ${p.name}: thanks!` : `You already voted for ${p.name}.`
|
|
81
|
+
})
|
|
82
|
+
} else if (action === 'launch') {
|
|
83
|
+
const url = s.typing!.trim()
|
|
84
|
+
s.typing = null
|
|
85
|
+
void run(`Reading ${url}…`, async () => {
|
|
86
|
+
const r = await client.launch(url)
|
|
87
|
+
s.flash = r.existing ? `${r.product.name} is already here.` : `Launched ${r.product.name}. Yours? builtindie claim ${r.product.slug}`
|
|
88
|
+
s.window = WINDOWS.indexOf('new')
|
|
89
|
+
s.products = (await client.list('new', 50)).products
|
|
90
|
+
s.at = 0
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
app.invalidate()
|
|
94
|
+
})
|
|
95
|
+
app.render(({ ui, theme }: any) => {
|
|
96
|
+
ui.row({ size: 1 }, (h: any) => {
|
|
97
|
+
h.text(' built.indie', { fg: theme.accent, bold: true, size: 13 })
|
|
98
|
+
h.text(WINDOWS.map((w, i) => (i === s.window ? `[${w}]` : w)).join(' '), { fg: theme.text })
|
|
99
|
+
h.text(`${client.base.replace(/^https?:\/\//, '')} `, { fg: theme.muted, align: 'right' })
|
|
100
|
+
})
|
|
101
|
+
if (s.open) {
|
|
102
|
+
const p = s.open
|
|
103
|
+
ui.panel({ title: `${p.name} ${UP} ${p.votes}` }, (b: any) => {
|
|
104
|
+
b.text(p.tagline, { fg: theme.accent, bold: true })
|
|
105
|
+
b.text(p.description || '', { fg: theme.text })
|
|
106
|
+
b.text(`site ${p.url}`, { fg: theme.muted })
|
|
107
|
+
b.text(`page ${p.page}`, { fg: theme.muted })
|
|
108
|
+
if (p.tags.length) b.text(`tags ${p.tags.join(', ')}`, { fg: theme.muted })
|
|
109
|
+
if (p.videos.length) b.text(`video ${p.videos[0]!.url}`, { fg: theme.muted })
|
|
110
|
+
if (p.maker) b.text(`maker ${p.maker.name}${p.maker.verified ? ' ✓' : ''}`, { fg: theme.muted })
|
|
111
|
+
})
|
|
112
|
+
} else {
|
|
113
|
+
ui.panel({ title: s.busy ?? `${WINDOWS[s.window]} (${s.products.length})` }, (b: any) => {
|
|
114
|
+
if (!s.products.length) b.text(s.busy ? '' : 'Nothing here yet. Press l to launch yours.', { fg: theme.muted })
|
|
115
|
+
s.products.slice(Math.max(0, s.at - 15), Math.max(0, s.at - 15) + 30).forEach((p) => {
|
|
116
|
+
const i = s.products.indexOf(p)
|
|
117
|
+
b.text(`${i === s.at ? '›' : ' '} ${String(i + 1).padStart(2)} ${UP}${String(p.votes).padStart(4)} ${p.name}${p.verified ? ` ${OK}` : ''} ${p.tagline}`, { fg: i === s.at ? theme.accent : theme.text, bold: i === s.at })
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
if (s.typing !== null) ui.panel({ title: 'Launch a URL', size: 3 }, (b: any) => b.text(`${s.typing}▏`, { fg: theme.accent }))
|
|
122
|
+
else if (s.flash) ui.panel({ title: 'built.indie', size: 3 }, (b: any) => b.text(s.flash, { fg: theme.accent }))
|
|
123
|
+
ui.statusBar({ items: s.typing !== null ? [{ key: 'Enter', label: 'launch' }, { key: 'Esc', label: 'cancel' }] : [{ key: '←→', label: 'board' }, { key: '↑↓', label: 'pick' }, { key: 'Enter', label: 'open' }, { key: 'v', label: 'upvote' }, { key: 'l', label: 'launch' }, { key: 'q', label: 'quit' }], right: [{ label: 'built.indie' }] })
|
|
124
|
+
})
|
|
125
|
+
void load()
|
|
126
|
+
await app.start()
|
|
127
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = '0.1.3'
|