@profullstack/builtindie 0.1.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/builtindie",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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> the DNS TXT record that proves it is yours, then checks it
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; call again once it is in DNS and claimed comes back true.',
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/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.1.2'
1
+ export const VERSION = '0.1.3'