@profullstack/builtindie 0.1.2 → 0.1.4
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/package.json +1 -1
- package/src/cli.ts +8 -2
- package/src/client.ts +2 -2
- package/src/html.ts +50 -0
- package/src/mcp.ts +1 -1
- package/src/server.ts +11 -3
- package/src/service.ts +14 -3
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/builtindie",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "built.indie: launch your indie product with one URL. We fetch the logo, screenshots, videos, llms.txt, OpenSite and OpenProfile from your homepage; people upvote; you get a badge. Web (PWA), API, CLI, TUI and MCP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -28,7 +28,8 @@ export const HELP = `builtindie ${VERSION}: launch your indie product with one U
|
|
|
28
28
|
builtindie vote <slug>
|
|
29
29
|
builtindie preview <url> what we read from a site; nothing is saved
|
|
30
30
|
builtindie search <words>
|
|
31
|
-
builtindie claim <slug>
|
|
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
|
|
32
33
|
builtindie me your launches
|
|
33
34
|
builtindie edit <slug> [--name --tagline --description --tags a,b --video URL]
|
|
34
35
|
builtindie refetch <slug> read the homepage again
|
|
@@ -53,7 +54,7 @@ export function parseArgs(argv: string[]): { words: string[]; flags: Record<stri
|
|
|
53
54
|
if (a.startsWith('--')) {
|
|
54
55
|
const [k, v] = a.slice(2).split(/=(.*)/s) as [string, string | undefined]
|
|
55
56
|
if (v !== undefined) flags[k] = v
|
|
56
|
-
else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft'].includes(k)) flags[k] = argv[++i]!
|
|
57
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft', 'md'].includes(k)) flags[k] = argv[++i]!
|
|
57
58
|
else flags[k] = true
|
|
58
59
|
} else words.push(a)
|
|
59
60
|
}
|
|
@@ -172,6 +173,11 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
|
|
|
172
173
|
}
|
|
173
174
|
case 'claim': {
|
|
174
175
|
const slug = rest[0] ?? ''
|
|
176
|
+
if (flags.md) {
|
|
177
|
+
const r = await client.claimRecord(slug)
|
|
178
|
+
out.log(json ? JSON.stringify(r, null, 2) : r.markdown)
|
|
179
|
+
return 0
|
|
180
|
+
}
|
|
175
181
|
const r = await client.claim(slug)
|
|
176
182
|
if (r.claimed) return show(r, `${slug} is yours ✓. Edit it: builtindie edit ${slug} --tagline "…"`), 0
|
|
177
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(', ')})`)
|
package/src/client.ts
CHANGED
|
@@ -89,11 +89,11 @@ export class BuiltIndieClient {
|
|
|
89
89
|
return k
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord }> {
|
|
92
|
+
claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; markdown: string }> {
|
|
93
93
|
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/claim`, undefined, this.needKey())
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
claim(slug: string): Promise<{ claimed: boolean; looked: string[]; record: ClaimRecord }> {
|
|
96
|
+
claim(slug: string): Promise<{ claimed: boolean; looked: string[]; record: ClaimRecord; markdown?: string }> {
|
|
97
97
|
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/claim`, {}, this.needKey())
|
|
98
98
|
}
|
|
99
99
|
|
package/src/html.ts
CHANGED
|
@@ -330,8 +330,52 @@ ${d.error ? `<p class="note error" role="alert">${esc(d.error.message)}</p>` : '
|
|
|
330
330
|
})
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The claim as Markdown: a prompt to hand an AI agent (or a person) that has
|
|
335
|
+
* access to the domain's DNS. Everything it needs is in it: the exact record,
|
|
336
|
+
* where it goes, how to check it took, and how to finish the claim.
|
|
337
|
+
*/
|
|
338
|
+
export function claimMarkdown(origin: string, p: Product, record: { name: string; value: string }): string {
|
|
339
|
+
const bare = p.domain.replace(/^www\./, '')
|
|
340
|
+
const label = record.name.slice(0, -(bare.length + 1))
|
|
341
|
+
return `# Claim ${p.name} on built.indie: add one DNS TXT record
|
|
342
|
+
|
|
343
|
+
Prove control of \`${bare}\` so the listing ${origin}/p/${p.slug} can be claimed and edited.
|
|
344
|
+
|
|
345
|
+
## The record
|
|
346
|
+
|
|
347
|
+
| Field | Value |
|
|
348
|
+
| --- | --- |
|
|
349
|
+
| Type | \`TXT\` |
|
|
350
|
+
| Name / host | \`${record.name}\` (many DNS hosts want only \`${label}\`, relative to the zone) |
|
|
351
|
+
| Value / content | \`${record.value}\` |
|
|
352
|
+
| TTL | 300, or the host's default |
|
|
353
|
+
|
|
354
|
+
The same TXT value on \`${bare}\` itself also works, if a \`${label}\` name is not possible.
|
|
355
|
+
|
|
356
|
+
## Steps
|
|
357
|
+
|
|
358
|
+
1. Find the DNS zone that serves \`${bare}\`. It is the zone of the registered domain (for \`a.b.example.com\` that is usually \`example.com\`): check with \`dig +short NS ${bare}\`.
|
|
359
|
+
2. In that zone, add the TXT record above. Add it; do not replace or delete any existing TXT records (SPF, DKIM, other verifications).
|
|
360
|
+
3. Wait for it to be served, then check it from public resolvers (built.indie asks 1.1.1.1 and 9.9.9.9):
|
|
361
|
+
|
|
362
|
+
\`\`\`sh
|
|
363
|
+
dig +short TXT ${record.name} @1.1.1.1
|
|
364
|
+
# expect: "${record.value}"
|
|
365
|
+
\`\`\`
|
|
366
|
+
|
|
367
|
+
4. Finish the claim, any one of:
|
|
368
|
+
- open ${origin}/p/${p.slug}/claim (signed in as the same account) and press **Check DNS**
|
|
369
|
+
- \`BUILTINDIE_KEY=bi_… builtindie claim ${p.slug}\`
|
|
370
|
+
- \`curl -X POST ${origin}/api/v1/products/${p.slug}/claim -H 'authorization: Bearer bi_…'\` (answers \`{"claimed": true}\`)
|
|
371
|
+
|
|
372
|
+
The value is tied to one built.indie account: a record made for another account will not claim it for this one. The record can be removed once the claim succeeds.
|
|
373
|
+
`
|
|
374
|
+
}
|
|
375
|
+
|
|
333
376
|
export function claimPage(ctx: Ctx, d: { product: Product; record: { name: string; value: string }; owner: boolean; result?: { ok: boolean; looked: string[] } }): string {
|
|
334
377
|
const p = d.product
|
|
378
|
+
const md = claimMarkdown(ctx.origin, p, d.record)
|
|
335
379
|
const done = d.owner || d.result?.ok
|
|
336
380
|
return layout({
|
|
337
381
|
ctx,
|
|
@@ -351,6 +395,12 @@ ${d.result && !d.result.ok ? `<p class="note error" role="alert">We did not find
|
|
|
351
395
|
<p class="small muted">Value</p><div class="address"><code>${esc(d.record.value)}</code><button class="copy" data-copy="${esc(d.record.value)}">Copy</button></div>
|
|
352
396
|
<p class="small muted">A TXT record on ${esc(p.domain.replace(/^www\./, ''))} itself with the same value works too. You can remove it once claimed.</p>
|
|
353
397
|
<form method="post" action="/p/${esc(p.slug)}/claim" data-busy="Asking DNS…"><button class="btn">Check DNS</button></form>
|
|
398
|
+
</div>
|
|
399
|
+
<div class="card">
|
|
400
|
+
<h2 class="mini-h">${icon('terminal')} Have an AI do the DNS</h2>
|
|
401
|
+
<p class="muted small">Copy these instructions as Markdown and paste them to an agent (or a person) with access to your DNS. They hold your record, how to check it, and how to finish the claim.</p>
|
|
402
|
+
<div class="row"><button class="chip" data-copy="${esc(md)}">Copy as Markdown for AI</button><a class="chip" href="/p/${esc(p.slug)}/claim.md">Open as .md</a></div>
|
|
403
|
+
<details><summary class="small">Preview</summary><pre><code>${esc(md)}</code></pre></details>
|
|
354
404
|
</div>`}`,
|
|
355
405
|
})
|
|
356
406
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function createMcpServer(client = new BuiltIndieClient()): McpServer {
|
|
|
66
66
|
)
|
|
67
67
|
server.tool(
|
|
68
68
|
'claim_product',
|
|
69
|
-
'Claim a listing for its domain owner (needs BUILTINDIE_KEY). Returns the DNS TXT record to add
|
|
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
70
|
{ slug },
|
|
71
71
|
wrap((a: { slug: string }) => client.claim(a.slug)),
|
|
72
72
|
)
|
package/src/server.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
|
12
12
|
import { Auth, consoleMailer, resendMailer, safeNext, SESSION_COOKIE, validEmail } from './auth.js'
|
|
13
13
|
import { Store, type Product, type User, type Window } from './db.js'
|
|
14
14
|
import { privateAddress } from './fetch.js'
|
|
15
|
-
import { badgeSvg, boardPage, cardImage, claimPage, editPage, esc, helpPage, homePage, loginPage, makerPage, mePage, messagePage, productPage, submitPage, type Ctx } from './html.js'
|
|
15
|
+
import { badgeSvg, boardPage, cardImage, claimMarkdown, claimPage, editPage, esc, helpPage, homePage, loginPage, makerPage, mePage, messagePage, productPage, submitPage, type Ctx } from './html.js'
|
|
16
16
|
import { IMAGE_TYPES, Media } from './media.js'
|
|
17
17
|
import { RING_PLACEHOLDER, ringBar, startRingRefresh, visitorOnMoshpit } from './ring.js'
|
|
18
18
|
import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX, DEFAULT_CHAINS, InputError, type Config, type Edit } from './service.js'
|
|
@@ -236,6 +236,14 @@ export function createApp(deps: Deps) {
|
|
|
236
236
|
const claimRecord = (p: Product, user: User) => ({ type: 'TXT', name: `${CLAIM_LABEL}.${p.domain.replace(/^www\./, '')}`, value: `${CLAIM_PREFIX}${service.claimToken(p, user)}` })
|
|
237
237
|
const claimView = (c: Context<Env>, p: Product, user: User, extra: { result?: { ok: boolean; looked: string[] } } = {}, status = 200) =>
|
|
238
238
|
page(c, claimPage(ctxOf(c), { product: p, record: claimRecord(p, user), owner: p.ownerId === user.id, ...extra }), status)
|
|
239
|
+
// The same instructions as Markdown, to paste to an AI agent with DNS access.
|
|
240
|
+
app.get('/p/:slug/claim.md', (c) => {
|
|
241
|
+
const user = sessionUser(c)
|
|
242
|
+
if (!user) return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`)
|
|
243
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
244
|
+
if (!p || p.status === 'hidden') return c.text('not found', 404)
|
|
245
|
+
return c.body(claimMarkdown(origin, p, claimRecord(p, user)), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'private, no-store' })
|
|
246
|
+
})
|
|
239
247
|
app.get('/p/:slug/claim', (c) => {
|
|
240
248
|
const user = sessionUser(c)
|
|
241
249
|
if (!user) return toLogin(c)
|
|
@@ -531,7 +539,7 @@ export function createApp(deps: Deps) {
|
|
|
531
539
|
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
532
540
|
const p = store.bySlug(c.req.param('slug'))
|
|
533
541
|
if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
|
|
534
|
-
return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user) })
|
|
542
|
+
return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user), markdown: claimMarkdown(origin, p, claimRecord(p, user)) })
|
|
535
543
|
})
|
|
536
544
|
app.post('/api/v1/products/:slug/claim', async (c) => {
|
|
537
545
|
const user = apiUser(c)
|
|
@@ -540,7 +548,7 @@ export function createApp(deps: Deps) {
|
|
|
540
548
|
if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
|
|
541
549
|
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many checks; wait a few minutes' }, 429)
|
|
542
550
|
const r = await service.claim(p, user)
|
|
543
|
-
return c.json({ claimed: r.ok, looked: r.looked, record: claimRecord(p, user) })
|
|
551
|
+
return c.json({ claimed: r.ok, looked: r.looked, record: claimRecord(p, user), ...(r.ok ? {} : { markdown: claimMarkdown(origin, p, claimRecord(p, user)) }) })
|
|
544
552
|
})
|
|
545
553
|
app.get('/api/v1/me', (c) => {
|
|
546
554
|
const user = apiUser(c)
|
package/src/service.ts
CHANGED
|
@@ -47,9 +47,20 @@ export type Config = {
|
|
|
47
47
|
export const CLAIM_LABEL = '_built-indie'
|
|
48
48
|
export const CLAIM_PREFIX = 'built-indie-verify='
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
/**
|
|
51
|
+
* TXT records as public resolvers see them: every answer from 1.1.1.1 and 9.9.9.9, merged.
|
|
52
|
+
* One Resolver per server on purpose: Bun 1.4's node:dns Resolver given two servers
|
|
53
|
+
* answers ENOTFOUND for a record each of them serves alone, so no claim could succeed.
|
|
54
|
+
*/
|
|
55
|
+
const PUBLIC_DNS = ['1.1.1.1', '9.9.9.9'].map((server) => {
|
|
56
|
+
const r = new Resolver({ timeout: 4000, tries: 2 })
|
|
57
|
+
r.setServers([server])
|
|
58
|
+
return r
|
|
59
|
+
})
|
|
60
|
+
export async function publicTxt(name: string, resolvers: Pick<Resolver, 'resolveTxt'>[] = PUBLIC_DNS): Promise<string[][]> {
|
|
61
|
+
const answers = await Promise.all(resolvers.map((r) => r.resolveTxt(name).catch(() => [] as string[][])))
|
|
62
|
+
return answers.flat()
|
|
63
|
+
}
|
|
53
64
|
|
|
54
65
|
export const CHAIN_LABELS: Record<string, string> = {
|
|
55
66
|
USDC_POL: 'USDC on Polygon', USDC_SOL: 'USDC on Solana', USDC_ETH: 'USDC on Ethereum', USDT_POL: 'USDT on Polygon',
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.1.
|
|
1
|
+
export const VERSION = '0.1.4'
|