@profullstack/builtindie 0.1.4 → 0.7.1

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/src/browse.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Browse and search: one query over every live launch, with facets.
3
+ *
4
+ * A launch's tags are whatever its page or its maker said (often nothing), so
5
+ * we also file each one under a fixed set of categories, read from its tags,
6
+ * name, tagline and description. Every listing lands somewhere, and the same
7
+ * twenty words mean the same thing everywhere.
8
+ *
9
+ * Facets work the usual way: values within a facet are OR'd, facets are AND'd,
10
+ * and each facet counts what you would get by changing only that facet.
11
+ */
12
+ import type { Product } from './db.js'
13
+
14
+ export type Category = { key: string; label: string; words: string[] }
15
+
16
+ export const CATEGORIES: Category[] = [
17
+ { key: 'developer-tools', label: 'Developer tools', words: ['developer', 'developers', 'devtools', 'dev tools', 'api', 'sdk', 'cli', 'code', 'coding', 'github', 'npm', 'open source', 'programming', 'terminal', 'mcp', 'cicd', 'ci/cd', 'git'] },
18
+ { key: 'ai', label: 'AI', words: ['ai', 'llm', 'llms', 'gpt', 'agent', 'agents', 'agentic', 'machine learning', 'artificial intelligence', 'chatbot', 'claude', 'openai', 'inference', 'gpu'] },
19
+ { key: 'crypto', label: 'Crypto & web3', words: ['crypto', 'cryptocurrency', 'bitcoin', 'blockchain', 'web3', 'usdc', 'ethereum', 'solana', 'defi', 'nft', 'multisig', 'escrow', 'stablecoin'] },
20
+ { key: 'finance', label: 'Finance & payments', words: ['finance', 'payment', 'payments', 'invoice', 'invoicing', 'billing', 'bank', 'banking', 'money', 'tax', 'taxes', 'accounting', 'stocks', 'trading', 'invest', 'investing', 'payment gateway'] },
21
+ { key: 'security', label: 'Security & privacy', words: ['security', 'cybersecurity', 'malware', 'vulnerability', 'vulnerabilities', 'threat', 'threats', 'privacy', 'encryption', 'encrypted', 'scanner', 'intrusion', 'firewall', 'vpn', 'backdoor', 'exposure management'] },
22
+ { key: 'productivity', label: 'Productivity', words: ['productivity', 'utilities', 'utility', 'workflow', 'automation', 'automate', 'notes', 'calendar', 'todo', 'bookmarks', 'sync', 'pdf'] },
23
+ { key: 'marketing', label: 'Marketing & SEO', words: ['marketing', 'seo', 'newsletter', 'newsletters', 'social media', 'ads', 'advertising', 'outreach', 'growth', 'analytics', 'leads', 'programmatic seo', 'affiliate', 'webring'] },
24
+ { key: 'media', label: 'Video & streaming', words: ['entertainment', 'movies', 'movie', 'video', 'videos', 'streaming', 'tv', 'iptv', 'torrent', 'torrents', 'film', 'films', 'youtube', 'ebooks', 'podcast', 'podcasts'] },
25
+ { key: 'music', label: 'Music & audio', words: ['music', 'audio', 'songs', 'radio', 'playlist', 'playlists', 'genre', 'genres', 'bands', 'concerts'] },
26
+ { key: 'news', label: 'News & publishing', words: ['news', 'blog', 'blogs', 'blogging', 'rss', 'feeds', 'journalism', 'publishing', 'magazine', 'content automation'] },
27
+ { key: 'education', label: 'Education', words: ['education', 'learn', 'learning', 'course', 'courses', 'training', 'tutorial', 'tutorials', 'school', 'students', 'kids'] },
28
+ { key: 'business', label: 'Business & SaaS', words: ['business', 'saas', 'b2b', 'crm', 'startup', 'startups', 'enterprise', 'small business', 'compliance', 'license', 'government'] },
29
+ { key: 'jobs', label: 'Jobs & freelance', words: ['jobs', 'job', 'hiring', 'freelance', 'freelancers', 'gig', 'gig economy', 'remote work', 'career', 'resume', 'recruiting'] },
30
+ { key: 'shopping', label: 'Shopping & deals', words: ['shopping', 'coupons', 'coupon', 'deals', 'discounts', 'promo codes', 'ecommerce', 'e-commerce', 'savings', 'marketplace'] },
31
+ { key: 'social', label: 'Social & community', words: ['social', 'community', 'communities', 'chat', 'forum', 'forums', 'messaging', 'messenger', 'dating', 'friends', 'sms'] },
32
+ { key: 'games', label: 'Games', words: ['game', 'games', 'gaming', 'esports', 'puzzle', 'play both sides', 'rpg', 'operatives', 'multiplayer', 'arcade'] },
33
+ { key: 'sports', label: 'Sports', words: ['sports', 'sport', 'youth-sports', 'athlete', 'athletes', 'league', 'leagues', 'nba', 'nfl', 'mlb', 'nhl', 'soccer', 'football', 'basketball', 'baseball', 'hockey', 'highlight reel', 'highlights', 'fixtures'] },
34
+ { key: 'design', label: 'Design & images', words: ['design', 'designer', 'image', 'images', 'photo', 'photos', 'background', 'backgrounds', 'icons', 'logo', 'logos', 'graphics', 'illustration', 'art', 'tattoo'] },
35
+ { key: 'lifestyle', label: 'Lifestyle & health', words: ['lifestyle', 'health', 'fitness', 'food', 'recipe', 'recipes', 'travel', 'family', 'ancestry', 'genealogy', 'home', 'pets', 'cannabis', 'weed', 'delivery', 'cigars', 'cigar', 'tattoo', 'detailing', 'bbq'] },
36
+ { key: 'data', label: 'Data & directories', words: ['directory', 'directories', 'dataset', 'datasets', 'database', 'databases', 'search engine', 'listings', 'index', 'scraper', 'lookup'] },
37
+ { key: 'hosting', label: 'Hosting & infrastructure', words: ['hosting', 'server', 'servers', 'vps', 'dns', 'domains', 'domain names', 'cloud', 'seedbox', 'bare metal', 'cdn', 'deploy', 'deployment', 'infrastructure'] },
38
+ ]
39
+ const OTHER: Category = { key: 'other', label: 'Other', words: [] }
40
+ export const CATEGORY_LABELS: Record<string, string> = Object.fromEntries([...CATEGORIES, OTHER].map((c) => [c.key, c.label]))
41
+
42
+ const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
43
+ const MATCHERS = CATEGORIES.map((c) => ({ key: c.key, re: new RegExp(`(?:^|[^a-z0-9])(?:${c.words.map(escRe).join('|')})(?=$|[^a-z0-9])`, 'g') }))
44
+
45
+ /**
46
+ * Up to three categories for a launch, best first. Tags and the tagline say
47
+ * what it is; the description only breaks ties, so a passing mention of
48
+ * "payments" in a long paragraph does not file a music site under finance.
49
+ */
50
+ export function categorize(p: Pick<Product, 'tags' | 'name' | 'tagline' | 'description'>): string[] {
51
+ const strong = ` ${[...p.tags, p.name, p.tagline].join(' · ').toLowerCase()} `
52
+ const weak = ` ${p.description.toLowerCase()} `
53
+ const scored = MATCHERS.map((m) => ({ key: m.key, score: 3 * (strong.match(m.re)?.length ?? 0) + Math.min(2, weak.match(m.re)?.length ?? 0) }))
54
+ .filter((s) => s.score > 0)
55
+ .sort((a, b) => b.score - a.score)
56
+ // A lone weak hit is noise unless it is all we have.
57
+ const keep = scored.filter((s, i) => i === 0 || s.score >= 2).slice(0, 3)
58
+ return keep.length ? keep.map((s) => s.key) : [OTHER.key]
59
+ }
60
+
61
+ /** Things a launch has, each one a filter. */
62
+ export const HAS: Record<string, { label: string; test: (p: Product, now: number) => boolean }> = {
63
+ verified: { label: '✓ Links back (verified)', test: (p) => Boolean(p.verifiedAt) },
64
+ claimed: { label: 'Claimed by its owner', test: (p) => Boolean(p.ownerId) },
65
+ featured: { label: '★ Featured now', test: (p, now) => Boolean(p.featuredUntil && p.featuredUntil > now) },
66
+ video: { label: 'Has a video', test: (p) => p.videos.length > 0 },
67
+ screenshots: { label: 'Has screenshots', test: (p) => Boolean(p.screenshot || p.images.length) },
68
+ llms: { label: 'llms.txt', test: (p) => Boolean(p.llms) },
69
+ opensite: { label: 'OpenSite', test: (p) => p.record?.source === 'declared' },
70
+ openprofile: { label: 'OpenProfile', test: (p) => Boolean(p.profile) },
71
+ openstack: { label: 'OpenStack (built with)', test: (p) => Boolean(p.extras.stack) },
72
+ opensaas: { label: 'OpenSaaS (pricing)', test: (p) => Boolean(p.extras.pricing) },
73
+ maker: { label: 'Names its maker', test: (p) => Boolean(p.makerName || p.makerId) },
74
+ }
75
+
76
+ const DAY = 86400_000
77
+ export const LAUNCHED: Record<string, { label: string; ms: number }> = {
78
+ today: { label: 'Last 24 hours', ms: DAY },
79
+ week: { label: 'Past week', ms: 7 * DAY },
80
+ month: { label: 'Past month', ms: 30 * DAY },
81
+ year: { label: 'Past year', ms: 365 * DAY },
82
+ }
83
+
84
+ export const SORTS = { relevance: 'Best match', top: 'Most upvoted', new: 'Newest', name: 'Name A–Z' } as const
85
+ export type Sort = keyof typeof SORTS
86
+
87
+ export type Query = { q: string; category: string[]; tag: string[]; stack: string[]; has: string[]; maker: string[]; launched: string | null; sort: Sort; page: number }
88
+ export type FacetName = 'category' | 'tag' | 'stack' | 'has' | 'maker' | 'launched'
89
+ export type FacetValue = { value: string; label: string; count: number }
90
+ export type Facets = Record<FacetName, FacetValue[]>
91
+ export type Chip = { facet: FacetName | 'q'; value: string; label: string }
92
+ export type Result = { query: Query; total: number; products: Product[]; facets: Facets; chips: Chip[]; categories: Map<string, string[]>; limit: number }
93
+
94
+ /** The order facets appear in, with their headings. */
95
+ export const FACETS: { name: FacetName; label: string; multi: boolean; top?: number }[] = [
96
+ { name: 'category', label: 'Category', multi: true },
97
+ { name: 'tag', label: 'Tags', multi: true, top: 30 },
98
+ { name: 'launched', label: 'Launched', multi: false },
99
+ { name: 'has', label: 'Has', multi: true },
100
+ { name: 'stack', label: 'Built with', multi: true, top: 20 },
101
+ { name: 'maker', label: 'Maker', multi: true, top: 20 },
102
+ ]
103
+
104
+ const clean = (v: string, max = 60) => v.trim().slice(0, max)
105
+ const listOf = (p: URLSearchParams, k: string, split: boolean) =>
106
+ [...new Set(p.getAll(k).flatMap((v) => (split ? v.split(',') : [v])).map((v) => clean(v)).filter(Boolean))].slice(0, 20)
107
+
108
+ /** A query from a URL's parameters (pages and the API take the same ones). */
109
+ export function parseQuery(params: URLSearchParams | Record<string, string | string[] | undefined>): Query {
110
+ const p = params instanceof URLSearchParams ? params : new URLSearchParams(Object.entries(params).flatMap(([k, v]) => (v === undefined ? [] : (Array.isArray(v) ? v : [v]).map((x) => [k, String(x)]))))
111
+ const q = clean(p.get('q') ?? '', 80)
112
+ const sort = (p.get('sort') ?? '') as Sort
113
+ const launched = p.get('launched') ?? ''
114
+ return {
115
+ q,
116
+ category: listOf(p, 'category', true).filter((c) => c in CATEGORY_LABELS),
117
+ tag: listOf(p, 'tag', true).map((t) => t.toLowerCase()),
118
+ stack: listOf(p, 'stack', false),
119
+ has: listOf(p, 'has', true).filter((h) => h in HAS),
120
+ maker: listOf(p, 'maker', false),
121
+ launched: launched in LAUNCHED ? launched : null,
122
+ sort: sort in SORTS && (sort !== 'relevance' || q) ? sort : q ? 'relevance' : 'top',
123
+ page: Math.min(500, Math.max(1, Number(p.get('page')) || 1)),
124
+ }
125
+ }
126
+
127
+ /** The query as URL parameters; defaults left out so links stay short. */
128
+ export function queryString(q: Query): string {
129
+ const p = new URLSearchParams()
130
+ if (q.q) p.set('q', q.q)
131
+ for (const k of ['category', 'tag', 'has', 'stack', 'maker'] as const) for (const v of q[k]) p.append(k, v)
132
+ if (q.launched) p.set('launched', q.launched)
133
+ if (q.sort !== (q.q ? 'relevance' : 'top')) p.set('sort', q.sort)
134
+ if (q.page > 1) p.set('page', String(q.page))
135
+ return p.toString()
136
+ }
137
+
138
+ /** The same query with one facet value switched on or off (back to page 1). */
139
+ export function toggle(q: Query, facet: FacetName, value: string): Query {
140
+ const next: Query = { ...q, page: 1 }
141
+ if (facet === 'launched') next.launched = q.launched === value ? null : value
142
+ else next[facet] = q[facet].includes(value) ? q[facet].filter((v) => v !== value) : [...q[facet], value]
143
+ return next
144
+ }
145
+
146
+ export const isEmpty = (q: Query) => !q.q && !q.launched && !q.category.length && !q.tag.length && !q.stack.length && !q.has.length && !q.maker.length
147
+
148
+ const words = (s: string) => s.toLowerCase().split(/[^a-z0-9+#.]+/).filter((w) => w.length >= 2)
149
+
150
+ export function search(all: Product[], query: Query, o: { limit?: number; now?: number } = {}): Result {
151
+ const now = o.now ?? Date.now()
152
+ const limit = o.limit ?? 30
153
+ const categories = new Map(all.map((p) => [p.id, categorize(p)]))
154
+ const stackOf = (p: Product) => [...new Set((p.extras.stack?.items ?? []).map((i) => i.name))]
155
+
156
+ // Words: every one must appear somewhere; where it appears decides the score.
157
+ // A term matches at the start of a word: "crypt" finds crypto, "ai" does not find email.
158
+ const terms = words(query.q).map((t) => ({ t, re: new RegExp(`(?:^|[^a-z0-9])${escRe(t)}`) }))
159
+ const scores = new Map<string, number>()
160
+ const textMatch = (p: Product) => {
161
+ if (!terms.length) return true
162
+ const name = p.name.toLowerCase()
163
+ const fields = [
164
+ [name, 8],
165
+ [p.tags.join(' ').toLowerCase(), 5],
166
+ [(categories.get(p.id) ?? []).map((c) => CATEGORY_LABELS[c]).join(' ').toLowerCase(), 4],
167
+ [p.domain.toLowerCase(), 4],
168
+ [p.tagline.toLowerCase(), 3],
169
+ [`${p.makerName} ${stackOf(p).join(' ')}`.toLowerCase(), 2],
170
+ [p.description.toLowerCase(), 1],
171
+ ] as const
172
+ let score = 0
173
+ for (const { re } of terms) {
174
+ const hit = fields.reduce((best, [text, w]) => (re.test(text) ? Math.max(best, w) : best), 0)
175
+ if (!hit) return false
176
+ score += hit
177
+ }
178
+ if (name === query.q.toLowerCase()) score += 20
179
+ else if (name.startsWith(terms[0]!.t)) score += 6
180
+ scores.set(p.id, score)
181
+ return true
182
+ }
183
+
184
+ const tests: Record<FacetName, (p: Product) => boolean> = {
185
+ category: (p) => !query.category.length || query.category.some((c) => categories.get(p.id)!.includes(c)),
186
+ tag: (p) => !query.tag.length || query.tag.some((t) => p.tags.includes(t)),
187
+ stack: (p) => !query.stack.length || stackOf(p).some((s) => query.stack.includes(s)),
188
+ has: (p) => query.has.every((h) => HAS[h]!.test(p, now)),
189
+ maker: (p) => !query.maker.length || query.maker.includes(p.makerName),
190
+ launched: (p) => !query.launched || Boolean(p.launchedAt && p.launchedAt >= now - LAUNCHED[query.launched]!.ms),
191
+ }
192
+ const base = all.filter(textMatch)
193
+ const names = Object.keys(tests) as FacetName[]
194
+ const passes = (p: Product, except?: FacetName) => names.every((n) => n === except || tests[n](p))
195
+
196
+ // Each facet counts over everything the other facets allow.
197
+ const tally = (except: FacetName, valuesOf: (p: Product) => string[]) => {
198
+ const n = new Map<string, number>()
199
+ for (const p of base) if (passes(p, except)) for (const v of valuesOf(p)) n.set(v, (n.get(v) ?? 0) + 1)
200
+ return n
201
+ }
202
+ const ranked = (n: Map<string, number>, label: (v: string) => string, top = 100, selected: string[] = []) =>
203
+ [...n]
204
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
205
+ .filter(([v], i) => i < top || selected.includes(v))
206
+ .map(([value, count]) => ({ value, label: label(value), count }))
207
+ const withSelected = (values: FacetValue[], selected: string[], label: (v: string) => string) => [...values, ...selected.filter((s) => !values.some((v) => v.value === s)).map((s) => ({ value: s, label: label(s), count: 0 }))]
208
+
209
+ const hasCounts = new Map<string, number>()
210
+ for (const p of base) if (passes(p, 'has')) for (const [k, h] of Object.entries(HAS)) if (query.has.filter((x) => x !== k).every((x) => HAS[x]!.test(p, now)) && h.test(p, now)) hasCounts.set(k, (hasCounts.get(k) ?? 0) + 1)
211
+ const launchedCounts = new Map<string, number>()
212
+ for (const p of base) if (passes(p, 'launched')) for (const [k, l] of Object.entries(LAUNCHED)) if (p.launchedAt && p.launchedAt >= now - l.ms) launchedCounts.set(k, (launchedCounts.get(k) ?? 0) + 1)
213
+
214
+ const facets: Facets = {
215
+ category: withSelected(ranked(tally('category', (p) => categories.get(p.id)!), (v) => CATEGORY_LABELS[v] ?? v), query.category, (v) => CATEGORY_LABELS[v] ?? v),
216
+ tag: withSelected(ranked(tally('tag', (p) => p.tags), (v) => v, 30, query.tag), query.tag, (v) => v),
217
+ stack: withSelected(ranked(tally('stack', stackOf), (v) => v, 20, query.stack), query.stack, (v) => v),
218
+ maker: withSelected(ranked(tally('maker', (p) => (p.makerName ? [p.makerName] : [])), (v) => v, 20, query.maker), query.maker, (v) => v),
219
+ has: Object.keys(HAS).filter((k) => hasCounts.get(k) || query.has.includes(k)).map((k) => ({ value: k, label: HAS[k]!.label, count: hasCounts.get(k) ?? 0 })),
220
+ launched: Object.keys(LAUNCHED).filter((k) => launchedCounts.get(k) || query.launched === k).map((k) => ({ value: k, label: LAUNCHED[k]!.label, count: launchedCounts.get(k) ?? 0 })),
221
+ }
222
+
223
+ const matched = base.filter((p) => passes(p))
224
+ const by: Record<Sort, (a: Product, b: Product) => number> = {
225
+ relevance: (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0) || b.votes - a.votes,
226
+ top: (a, b) => b.votes - a.votes || (b.launchedAt ?? 0) - (a.launchedAt ?? 0),
227
+ new: (a, b) => (b.launchedAt ?? 0) - (a.launchedAt ?? 0),
228
+ name: (a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }),
229
+ }
230
+ matched.sort(by[query.sort])
231
+
232
+ const chips: Chip[] = [
233
+ ...(query.q ? [{ facet: 'q' as const, value: query.q, label: `“${query.q}”` }] : []),
234
+ ...query.category.map((v) => ({ facet: 'category' as const, value: v, label: CATEGORY_LABELS[v] ?? v })),
235
+ ...query.tag.map((v) => ({ facet: 'tag' as const, value: v, label: `#${v}` })),
236
+ ...(query.launched ? [{ facet: 'launched' as const, value: query.launched, label: LAUNCHED[query.launched]!.label }] : []),
237
+ ...query.has.map((v) => ({ facet: 'has' as const, value: v, label: HAS[v]!.label })),
238
+ ...query.stack.map((v) => ({ facet: 'stack' as const, value: v, label: `Built with ${v}` })),
239
+ ...query.maker.map((v) => ({ facet: 'maker' as const, value: v, label: `By ${v}` })),
240
+ ]
241
+ const start = (query.page - 1) * limit
242
+ return { query, total: matched.length, products: matched.slice(start, start + limit), facets, chips, categories, limit }
243
+ }
package/src/cli.ts CHANGED
@@ -7,9 +7,23 @@
7
7
  * builtindie show <slug>
8
8
  * builtindie vote <slug>
9
9
  * builtindie preview <url> what we would read from a site, nothing saved
10
- * builtindie search <words>
11
- * builtindie claim <slug> prove you own the domain (DNS TXT), BUILTINDIE_KEY
10
+ * builtindie tags [tag] every tag, or the launches with one
11
+ * builtindie search [words] [--category k] [--tag t] [--has h] [--sort top|new|name]
12
+ * builtindie categories every category and tag, with counts
13
+ * builtindie claim <slug> prove you own the domain (the badge or DNS TXT), BUILTINDIE_KEY
12
14
  * builtindie me your launches, BUILTINDIE_KEY
15
+ * builtindie invite your invite link and free Featured credits, BUILTINDIE_KEY
16
+ * builtindie credit <slug> spend a credit: feature a listing you own, BUILTINDIE_KEY
17
+ * builtindie redeem <code> a credit code: Featured days to your balance, BUILTINDIE_KEY
18
+ * builtindie invite-code [note] one of your 3 single-use invite codes, BUILTINDIE_KEY
19
+ * builtindie feature-days <slug> <n> spend Featured days on a listing you own, BUILTINDIE_KEY
20
+ * builtindie feature <slug> pay to feature it: a CoinPay checkout link (any coin or PayPal), BUILTINDIE_KEY
21
+ * builtindie karma your karma, history, next free Featured week, BUILTINDIE_KEY
22
+ * builtindie leaderboard [karma|invites] [--period week|month|all]
23
+ * builtindie comment <slug> "text" comment on a launch (--reply <id>), BUILTINDIE_KEY
24
+ * builtindie comments <slug> read the comments
25
+ * builtindie review <slug> <1-5> "text" review a launch, BUILTINDIE_KEY
26
+ * builtindie reviews <slug> read the reviews
13
27
  * builtindie edit <slug> --tagline … (BUILTINDIE_KEY)
14
28
  * builtindie refetch <slug> (BUILTINDIE_KEY)
15
29
  * builtindie mcp MCP server on stdio
@@ -27,10 +41,28 @@ export const HELP = `builtindie ${VERSION}: launch your indie product with one U
27
41
  builtindie show <slug>
28
42
  builtindie vote <slug>
29
43
  builtindie preview <url> what we read from a site; nothing is saved
30
- builtindie search <words>
31
- builtindie claim <slug> [--md] the DNS TXT record that proves it is yours, then checks it;
32
- --md prints the steps as Markdown to paste to an AI with DNS access
44
+ builtindie tags [tag] every tag with its count, or the launches tagged <tag>
45
+ builtindie search [words] search; narrow with --category developer-tools --tag ai
46
+ [--has verified,video] [--stack Bun] [--maker "Name"] [--launched today|week|month|year]
47
+ [--sort relevance|top|new|name] [--page N] [--facets]
48
+ builtindie categories every category and tag, with how many launches each
49
+ builtindie claim <slug> [--md] your badge for the homepage (or the DNS TXT record) that proves
50
+ it is yours, then checks both; --md prints the steps as Markdown
51
+ to paste to an AI with DNS or site access
33
52
  builtindie me your launches
53
+ builtindie invite your invite link; each maker it brings in who launches = a free Featured week
54
+ builtindie credit <slug> spend a credit to feature a listing you own
55
+ builtindie redeem <code> redeem a credit code: Featured days to your balance
56
+ builtindie invite-code [note] make one of your 3 single-use invite codes (both get Featured days)
57
+ builtindie feature-days <slug> <n> spend n Featured days on a listing you own
58
+ builtindie feature <slug> [--email you@x] pay to feature it: prints a CoinPay checkout link (any coin or PayPal)
59
+ builtindie karma your karma, history and how far to the next free Featured week
60
+ builtindie leaderboard [karma|invites] [--period week|month|all]
61
+ builtindie comment <slug> "text" comment on a launch (+2 karma); --reply <comment id> to answer one
62
+ builtindie comments <slug> read a launch's comments
63
+ builtindie uncomment <id> delete your comment (its karma goes back)
64
+ builtindie review <slug> <1-5> "text" stars and words (+5 karma for 30+ characters)
65
+ builtindie reviews <slug> read a launch's reviews
34
66
  builtindie edit <slug> [--name --tagline --description --tags a,b --video URL]
35
67
  builtindie refetch <slug> read the homepage again
36
68
  builtindie publish <slug> put a draft live
@@ -54,7 +86,7 @@ export function parseArgs(argv: string[]): { words: string[]; flags: Record<stri
54
86
  if (a.startsWith('--')) {
55
87
  const [k, v] = a.slice(2).split(/=(.*)/s) as [string, string | undefined]
56
88
  if (v !== undefined) flags[k] = v
57
- else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft', 'md'].includes(k)) flags[k] = argv[++i]!
89
+ else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft', 'md', 'facets'].includes(k)) flags[k] = argv[++i]!
58
90
  else flags[k] = true
59
91
  } else words.push(a)
60
92
  }
@@ -135,8 +167,26 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
135
167
  return 0
136
168
  }
137
169
  case 'search': {
138
- const r = await client.search(rest.join(' '))
139
- show(r, r.products.map((p) => line(p)).join('\n') || 'Nothing matches.')
170
+ const many = (k: string) => (typeof flags[k] === 'string' ? String(flags[k]).split(',').map((v) => v.trim()).filter(Boolean) : undefined)
171
+ const r = await client.search(rest.join(' '), { category: many('category'), tag: many('tag'), has: many('has'), stack: many('stack'), maker: typeof flags.maker === 'string' ? [flags.maker] : undefined, launched: str('launched'), sort: str('sort'), page: Number(str('page') ?? 1) || 1, limit: Number(str('limit') ?? 30) || 30 })
172
+ const facets = flags.facets && r.facets ? `\n\n${Object.entries(r.facets).filter(([, v]) => v.length).map(([k, v]) => `${k}: ${v.map((f) => `${f.value} (${f.count})`).join(', ')}`).join('\n')}` : ''
173
+ show(r, `${r.products.map((p) => line(p)).join('\n') || 'Nothing matches.'}\n\n${r.total} match${r.total === 1 ? '' : 'es'}${r.pages > 1 ? `, page ${r.page} of ${r.pages}` : ''} · ${r.web}${facets}`)
174
+ return 0
175
+ }
176
+ case 'categories': {
177
+ const r = await client.categories()
178
+ show(r, `${r.categories.map((c) => `${String(c.count).padStart(4)} ${c.label} (${c.key})`).join('\n')}\n\nTags: ${r.tags.map((t) => `${t.tag} (${t.count})`).join(', ')}`)
179
+ return 0
180
+ }
181
+ case 'tags':
182
+ case 'tag': {
183
+ if (rest.length) {
184
+ const r = await client.tagged(rest.join(' '), Number(str('limit') ?? 30))
185
+ show(r, r.products.map((p, i) => line(p, i + 1)).join('\n') || `Nothing tagged ${r.tag} yet.`)
186
+ } else {
187
+ const r = await client.tags()
188
+ show(r, r.tags.map((t) => `${String(t.count).padStart(4)} ${t.tag}`).join('\n') || 'No tags yet.')
189
+ }
140
190
  return 0
141
191
  }
142
192
  case 'show': {
@@ -179,8 +229,8 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
179
229
  return 0
180
230
  }
181
231
  const r = await client.claim(slug)
182
- if (r.claimed) return show(r, `${slug} is yours ✓. Edit it: builtindie edit ${slug} --tagline "…"`), 0
183
- show(r, `Add this DNS record, wait a minute, then run builtindie claim ${slug} again:\n\n type TXT\n name ${r.record.name}\n value ${r.record.value}\n\n(not found yet at ${r.looked.join(', ')})`)
232
+ if (r.claimed) return show(r, `${slug} is yours ✓${r.method === 'badge' ? ' (we found your badge)' : r.method === 'dns' ? ' (we found your DNS record)' : ''}. Edit it: builtindie edit ${slug} --tagline "…"`), 0
233
+ show(r, `Prove it either way, then run builtindie claim ${slug} again:\n\n1. Easiest: put this badge on your homepage and deploy (its id is yours; it also earns the followed link and the ✓):\n\n ${r.badge}\n\n2. Or add this DNS record (wait a minute for it to show):\n\n type TXT\n name ${r.record.name}\n value ${r.record.value}\n\n(not found yet at ${r.looked.join(', ')})`)
184
234
  return 1
185
235
  }
186
236
  case 'me': {
@@ -188,6 +238,77 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
188
238
  show(r, `${r.email}\n${r.products.map((p) => `${line(p)} ${p.owner ? 'claimed ✓' : p.status === 'draft' ? 'draft' : 'not claimed'}`).join('\n') || 'No launches yet.'}`)
189
239
  return 0
190
240
  }
241
+ case 'invite':
242
+ case 'invites': {
243
+ const r = await client.invites()
244
+ show(r, `Your invite link: ${r.link}\n${r.invited} joined · ${r.launched} launched · ${r.credits} free Featured week${r.credits === 1 ? '' : 's'} to use (${r.feature_days} days each)${r.credits ? '\nUse one: builtindie credit <slug>' : ''}`)
245
+ return 0
246
+ }
247
+ case 'credit': {
248
+ const r = await client.credit(rest[0] ?? '')
249
+ show(r, `${rest[0]} is Featured until ${r.featured_until.slice(0, 10)} ★ (${r.credits} credit${r.credits === 1 ? '' : 's'} left)`)
250
+ return 0
251
+ }
252
+ case 'redeem': {
253
+ const r = await client.redeem(rest[0] ?? '')
254
+ show(r, `${r.message}\nFeatured days: ${r.featured_days}${r.featured_days ? ' (use them: builtindie feature-days <slug> <n>)' : ''}`)
255
+ return 0
256
+ }
257
+ case 'invite-code': {
258
+ const r = await client.inviteCode(rest.join(' '))
259
+ show(r, `Your invite (works once, for someone new; you both get ${r.bonus_days} Featured days):\n${r.link}\n${r.left} left`)
260
+ return 0
261
+ }
262
+ case 'feature-days': {
263
+ const r = await client.featureDays(rest[0] ?? '', Number(rest[1] ?? 0))
264
+ show(r, `${rest[0]} is Featured until ${r.featured_until.slice(0, 10)} ★ (${r.featured_days} day${r.featured_days === 1 ? '' : 's'} left)`)
265
+ return 0
266
+ }
267
+ case 'feature': {
268
+ const r = await client.feature(rest[0] ?? '', str('email'))
269
+ show(r, `Pay $${r.usd} for ${r.days} days Featured (${r.methods.join(', ')}):\n${r.checkout_url}`)
270
+ return 0
271
+ }
272
+ case 'karma': {
273
+ const r = await client.karma()
274
+ show(r, `${r.total} karma (${r.week >= 0 ? '+' : ''}${r.week} this week) · ${r.next_credit_in} to your next free Featured week · ${r.credits.earned} earned from karma\n\n${r.events.map((e) => `${(e.points > 0 ? '+' : '') + e.points}`.padStart(4) + ` ${e.label} ${e.at.slice(0, 10)}`).join('\n') || 'No karma yet. How to earn:\n' + r.how_to_earn.map((h) => ` +${h.points} ${h.action}: ${h.rule}`).join('\n')}`)
275
+ return 0
276
+ }
277
+ case 'leaderboard':
278
+ case 'board': {
279
+ const r = await client.leaderboard(rest[0] ?? 'karma', str('period') ?? 'week', Number(str('limit') ?? 25))
280
+ show(r, `${r.board === 'invites' ? 'Top inviters' : 'Top karma'} (${r.period})\n${r.rows.map((x) => `${String(x.rank ?? '').padStart(3)}. ${String(x.value).padStart(5)} ${x.name} ${x.badges.map((b) => b.emoji).join('')}`).join('\n') || 'Nobody yet.'}\n${r.web}`)
281
+ return 0
282
+ }
283
+ case 'comment': {
284
+ const [slug, ...text] = rest
285
+ if (!slug || !text.length) throw new Error('builtindie comment <slug> "text" [--reply <comment id>]')
286
+ const r = await client.comment(slug, text.join(' '), str('reply'))
287
+ show(r, `Posted: ${r.comment.url} (you have ${r.karma} karma)`)
288
+ return 0
289
+ }
290
+ case 'comments': {
291
+ const r = await client.comments(rest[0] ?? '')
292
+ show(r, r.comments.map((x) => `${x.parent ? ' ↳ ' : ''}${x.deleted ? '[deleted]' : `${x.author} (${x.karma} karma)${x.maker ? ' [maker]' : ''} ${x.at.slice(0, 10)} #${x.id}\n${x.parent ? ' ' : ' '}${x.body}`}`).join('\n') || 'No comments yet.')
293
+ return 0
294
+ }
295
+ case 'uncomment': {
296
+ const r = await client.deleteComment(rest[0] ?? '')
297
+ show(r, 'Deleted.')
298
+ return 0
299
+ }
300
+ case 'review': {
301
+ const [slug, stars, ...text] = rest
302
+ if (!slug || !stars) throw new Error('builtindie review <slug> <1-5> "text"')
303
+ const r = await client.review(slug, Number(stars), text.join(' '))
304
+ show(r, `${'★'.repeat(r.review.rating)} saved. ${slug} is rated ${r.product.rating} from ${r.product.count}. You have ${r.karma} karma.`)
305
+ return 0
306
+ }
307
+ case 'reviews': {
308
+ const r = await client.reviews(rest[0] ?? '')
309
+ show(r, `${r.rating === null ? 'No reviews yet.' : `★ ${r.rating} from ${r.count}`}\n${r.reviews.map((x) => `${'★'.repeat(x.rating)} ${x.author} (${x.karma} karma) ${x.at.slice(0, 10)}\n ${x.body}`).join('\n')}`)
310
+ return 0
311
+ }
191
312
  case 'verify': {
192
313
  const r = await client.verify(rest[0] ?? '')
193
314
  show(r, r.verified ? 'Verified ✓: we found the badge.' : 'Not found yet: add the badge HTML to your homepage and deploy.')
package/src/client.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  * BUILTINDIE_URL points it at another instance; BUILTINDIE_KEY (bi_…) is your
4
4
  * account's API key (built.indie/me), for claiming and editing your products.
5
5
  */
6
+ export type SearchFilters = Partial<{ category: string[]; tag: string[]; has: string[]; stack: string[]; maker: string[]; launched: string; sort: string; page: number; limit: number }>
7
+ export type FacetValue = { value: string; label: string; count: number }
8
+ export type SearchResult = { total: number; page: number; pages: number; products: (ApiProduct & { categories: string[] })[]; facets?: Record<string, FacetValue[]>; web: string }
9
+
6
10
  export type ApiProduct = {
7
11
  slug: string
8
12
  name: string
@@ -12,6 +16,8 @@ export type ApiProduct = {
12
16
  domain: string
13
17
  status: string
14
18
  votes: number
19
+ reviews?: number
20
+ rating?: number | null
15
21
  tags: string[]
16
22
  logo: string | null
17
23
  card_image: string | null
@@ -32,6 +38,13 @@ export type ApiProduct = {
32
38
  award?: { day: string; rank: number } | null
33
39
  }
34
40
 
41
+ export type Invites = { link: string; code: string; invited: number; launched: number; credits: number; karma_credits?: number; used: number; feature_days: number; invite_codes?: { quota: { total: number; used: number; left: number }; bonus_days: number; invites: { id: string; note: string; created_at: string; expires_at: string | null; used: boolean; used_at: string | null }[] } }
42
+
43
+ export type KarmaInfo = { total: number; week: number; per_credit: number; next_credit_in: number; credits: { earned: number; used: number }; events: { action: string; label: string; ref: string; points: number; at: string }[]; how_to_earn: { action: string; points: number; rule: string }[]; leaderboard: string }
44
+ export type BoardRows = { board: string; period: string; total: number; rows: { rank: number | null; id: string; name: string; value: number; badges: { emoji: string; label: string }[]; url: string }[]; web: string }
45
+ export type ApiComment = { id: string; parent: string | null; body: string | null; deleted: boolean; author: string | null; karma: number; maker: boolean; at: string; url: string; mine?: boolean }
46
+ export type ApiReview = { rating: number; body: string; author: string; karma: number; at: string }
47
+
35
48
  export type Launched = { product: ApiProduct; claim_url?: string; existing?: boolean }
36
49
  export type ClaimRecord = { type: string; name: string; value: string }
37
50
 
@@ -71,8 +84,27 @@ export class BuiltIndieClient {
71
84
  return this.call('GET', `/api/v1/products?window=${encodeURIComponent(window)}&limit=${limit}`)
72
85
  }
73
86
 
74
- search(q: string): Promise<{ products: ApiProduct[] }> {
75
- return this.call('GET', `/api/v1/products?q=${encodeURIComponent(q)}`)
87
+ tags(): Promise<{ tags: { tag: string; count: number }[] }> {
88
+ return this.call('GET', '/api/v1/tags')
89
+ }
90
+
91
+ tagged(tag: string, limit = 30): Promise<{ tag: string; products: ApiProduct[] }> {
92
+ return this.call('GET', `/api/v1/products?tag=${encodeURIComponent(tag)}&limit=${limit}`)
93
+ }
94
+
95
+ /** Search with filters; the answer carries the facets (counts per category, tag, stack…) to narrow further. */
96
+ search(q: string, filters: SearchFilters = {}): Promise<SearchResult> {
97
+ const p = new URLSearchParams()
98
+ if (q) p.set('q', q)
99
+ for (const k of ['category', 'tag', 'has', 'stack', 'maker'] as const) for (const v of filters[k] ?? []) p.append(k, v)
100
+ for (const k of ['launched', 'sort'] as const) if (filters[k]) p.set(k, filters[k]!)
101
+ if (filters.page) p.set('page', String(filters.page))
102
+ if (filters.limit) p.set('limit', String(filters.limit))
103
+ return this.call('GET', `/api/v1/search?${p}`)
104
+ }
105
+
106
+ categories(): Promise<{ categories: { key: string; label: string; count: number; page: string }[]; tags: { tag: string; count: number; page: string }[] }> {
107
+ return this.call('GET', '/api/v1/categories')
76
108
  }
77
109
 
78
110
  product(slug: string): Promise<ApiProduct> {
@@ -89,18 +121,48 @@ export class BuiltIndieClient {
89
121
  return k
90
122
  }
91
123
 
92
- claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; markdown: string }> {
124
+ claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; badge: string; markdown: string }> {
93
125
  return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/claim`, undefined, this.needKey())
94
126
  }
95
127
 
96
- claim(slug: string): Promise<{ claimed: boolean; looked: string[]; record: ClaimRecord; markdown?: string }> {
128
+ claim(slug: string): Promise<{ claimed: boolean; method?: 'dns' | 'badge'; looked: string[]; record: ClaimRecord; badge: string; markdown?: string }> {
97
129
  return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/claim`, {}, this.needKey())
98
130
  }
99
131
 
100
- me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[] }> {
132
+ me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[]; invite: Invites }> {
101
133
  return this.call('GET', '/api/v1/me', undefined, this.needKey())
102
134
  }
103
135
 
136
+ /** Your invite link and credits: each new member it brings in who launches earns a free Featured week. */
137
+ invites(): Promise<Invites> {
138
+ return this.call('GET', '/api/v1/invites', undefined, this.needKey())
139
+ }
140
+
141
+ /** Spend one invite credit to feature a listing you own. */
142
+ credit(slug: string): Promise<{ featured_until: string; credits: number }> {
143
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/credit`, {}, this.needKey())
144
+ }
145
+
146
+ /** Redeem a credit code: Featured days added to your balance. */
147
+ redeem(code: string): Promise<{ ok: boolean; message: string; featured_days: number }> {
148
+ return this.call('POST', '/api/v1/redeem', { code }, this.needKey())
149
+ }
150
+
151
+ /** Make one of your scarce single-use invite codes; the link is shown once. */
152
+ inviteCode(note = ''): Promise<{ link: string; code: string; expires_at: string | null; left: number; bonus_days: number }> {
153
+ return this.call('POST', '/api/v1/invites', { note }, this.needKey())
154
+ }
155
+
156
+ /** Spend Featured days from your balance on a listing you own. */
157
+ featureDays(slug: string, days: number): Promise<{ featured_until: string; featured_days: number }> {
158
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/feature-days`, { days }, this.needKey())
159
+ }
160
+
161
+ /** Pay to feature a listing you own: opens a CoinPay checkout session (any coin or PayPal); send the buyer to checkout_url. */
162
+ feature(slug: string, email?: string): Promise<{ checkout_url: string; checkout_session_id: string; methods: string[]; payment: string; usd: number; days: number }> {
163
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/feature`, email ? { email } : {}, this.needKey())
164
+ }
165
+
104
166
  edit(slug: string, patch: Record<string, unknown>, key?: string): Promise<ApiProduct> {
105
167
  return this.call('PATCH', `/api/v1/products/${encodeURIComponent(slug)}`, patch, this.needKey(key))
106
168
  }
@@ -109,6 +171,35 @@ export class BuiltIndieClient {
109
171
  return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/${action}`, {}, this.needKey(key))
110
172
  }
111
173
 
174
+ /** Your karma: total, this week, history, and how far to the next free Featured week. */
175
+ karma(): Promise<KarmaInfo> {
176
+ return this.call('GET', '/api/v1/karma', undefined, this.needKey())
177
+ }
178
+
179
+ leaderboard(board = 'karma', period = 'week', limit = 25): Promise<BoardRows> {
180
+ return this.call('GET', `/api/v1/leaderboard?board=${encodeURIComponent(board)}&period=${encodeURIComponent(period)}&limit=${limit}`)
181
+ }
182
+
183
+ comments(slug: string): Promise<{ slug: string; comments: ApiComment[] }> {
184
+ return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/comments`)
185
+ }
186
+
187
+ comment(slug: string, body: string, parent?: string): Promise<{ comment: ApiComment; karma: number }> {
188
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/comments`, { body, ...(parent ? { parent } : {}) }, this.needKey())
189
+ }
190
+
191
+ deleteComment(id: string): Promise<{ deleted: boolean }> {
192
+ return this.call('DELETE', `/api/v1/comments/${encodeURIComponent(id)}`, undefined, this.needKey())
193
+ }
194
+
195
+ reviews(slug: string): Promise<{ slug: string; rating: number | null; count: number; reviews: ApiReview[] }> {
196
+ return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/reviews`)
197
+ }
198
+
199
+ review(slug: string, rating: number, body: string): Promise<{ review: { rating: number; body: string }; product: { rating: number | null; count: number }; karma: number }> {
200
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/reviews`, { rating, body }, this.needKey())
201
+ }
202
+
112
203
  verify(slug: string): Promise<{ verified: boolean }> {
113
204
  return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/verify`, {})
114
205
  }