@profullstack/builtindie 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/OpenStack.md +74 -0
  3. package/README.md +63 -2
  4. package/bin/builtindie +3 -0
  5. package/package.json +35 -4
  6. package/public/app.js +35 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icon-192.png +0 -0
  9. package/public/icon-512.png +0 -0
  10. package/public/icons/openicon.json +574 -0
  11. package/public/icons/svg/apple.svg +1 -0
  12. package/public/icons/svg/arrow-up.svg +1 -0
  13. package/public/icons/svg/bluesky.svg +1 -0
  14. package/public/icons/svg/check.svg +1 -0
  15. package/public/icons/svg/chrome.svg +1 -0
  16. package/public/icons/svg/code.svg +1 -0
  17. package/public/icons/svg/discord.svg +1 -0
  18. package/public/icons/svg/dollar.svg +1 -0
  19. package/public/icons/svg/external-link.svg +1 -0
  20. package/public/icons/svg/github.svg +1 -0
  21. package/public/icons/svg/globe.svg +1 -0
  22. package/public/icons/svg/instagram.svg +1 -0
  23. package/public/icons/svg/layers.svg +1 -0
  24. package/public/icons/svg/link.svg +1 -0
  25. package/public/icons/svg/linkedin.svg +1 -0
  26. package/public/icons/svg/mail.svg +1 -0
  27. package/public/icons/svg/product-hunt.svg +1 -0
  28. package/public/icons/svg/reddit.svg +1 -0
  29. package/public/icons/svg/rocket.svg +1 -0
  30. package/public/icons/svg/search.svg +1 -0
  31. package/public/icons/svg/share.svg +1 -0
  32. package/public/icons/svg/star.svg +1 -0
  33. package/public/icons/svg/tag.svg +1 -0
  34. package/public/icons/svg/telegram.svg +1 -0
  35. package/public/icons/svg/terminal.svg +1 -0
  36. package/public/icons/svg/tiktok.svg +1 -0
  37. package/public/icons/svg/trophy.svg +1 -0
  38. package/public/icons/svg/user.svg +1 -0
  39. package/public/icons/svg/x.svg +1 -0
  40. package/public/icons/svg/youtube.svg +1 -0
  41. package/public/install.sh +44 -0
  42. package/public/manifest.webmanifest +20 -0
  43. package/public/moshpit-root-ca.crt +11 -0
  44. package/public/style.css +162 -0
  45. package/public/sw.js +30 -0
  46. package/src/auth.ts +96 -0
  47. package/src/cli.ts +203 -0
  48. package/src/client.ts +115 -0
  49. package/src/db.ts +412 -0
  50. package/src/fetch.ts +708 -0
  51. package/src/html.ts +525 -0
  52. package/src/icons.ts +56 -0
  53. package/src/mcp.ts +79 -0
  54. package/src/media.ts +160 -0
  55. package/src/ring.ts +86 -0
  56. package/src/server.ts +655 -0
  57. package/src/service.ts +369 -0
  58. package/src/tui.ts +127 -0
  59. package/src/version.ts +1 -0
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><title>Telegram</title><g transform="translate(2 2) scale(0.83333)"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><title>Terminal</title><path d="m4 17 6-6-6-6M12 19h8"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><title>TikTok</title><g transform="translate(2 2) scale(0.83333)"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><title>Trophy</title><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.7V17c0 .6-.5 1-1 1.2C7.8 18.8 7 20.2 7 22M14 14.7V17c0 .6.5 1 1 1.2 1.2.6 2 2 2 3.8M18 2H6v7a6 6 0 0 0 12 0z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><title>User</title><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><title>X</title><g transform="translate(2 2) scale(0.83333)"><path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><title>YouTube</title><g transform="translate(2 2) scale(0.83333)"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></g></svg>
@@ -0,0 +1,44 @@
1
+ #!/bin/sh
2
+ # built.indie CLI, TUI and MCP server: curl -fsSL https://built.indie.pit.world/install.sh | sh
3
+ # Installs @profullstack/builtindie with bun when you have it, else npm (Node 22+).
4
+ # No sudo: npm falls back to ~/.local. Run it again to upgrade.
5
+ set -eu
6
+
7
+ PKG="@profullstack/builtindie"
8
+ say() { printf '\033[1;31m▲\033[0m %s\n' "$*"; }
9
+ die() { printf 'built.indie install: %s\n' "$*" >&2; exit 1; }
10
+
11
+ if command -v bun >/dev/null 2>&1; then
12
+ # bun add -g writes into the nearest package.json above its global dir when that dir has none.
13
+ GLOBAL="${BUN_INSTALL:-$HOME/.bun}/install/global"
14
+ mkdir -p "$GLOBAL"
15
+ [ -f "$GLOBAL/package.json" ] || printf '{}\n' > "$GLOBAL/package.json"
16
+ say "Installing $PKG with bun"
17
+ bun add -g "$PKG@latest" >/dev/null
18
+ BIN_DIR="${BUN_INSTALL:-$HOME/.bun}/bin"
19
+ elif command -v npm >/dev/null 2>&1; then
20
+ NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)
21
+ [ "$NODE_MAJOR" -ge 22 ] || die "needs Node 22 or newer (you have $(node -v 2>/dev/null || echo none)), or bun: curl -fsSL https://bun.sh/install | bash"
22
+ # builtindie runs on bun; npm installs the launcher, which needs bun on PATH.
23
+ command -v bun >/dev/null 2>&1 || say "Note: builtindie runs on bun. Install it too: curl -fsSL https://bun.sh/install | bash"
24
+ PREFIX=$(npm config get prefix 2>/dev/null || echo /usr/local)
25
+ if [ -w "$PREFIX" ] || [ -w "$PREFIX/lib/node_modules" ] 2>/dev/null; then
26
+ say "Installing $PKG with npm"
27
+ npm install -g "$PKG@latest" >/dev/null
28
+ BIN_DIR="$PREFIX/bin"
29
+ else
30
+ say "Installing $PKG with npm into ~/.local"
31
+ npm install -g --prefix "$HOME/.local" "$PKG@latest" >/dev/null
32
+ BIN_DIR="$HOME/.local/bin"
33
+ fi
34
+ else
35
+ die "needs bun or npm. Get bun: curl -fsSL https://bun.sh/install | bash"
36
+ fi
37
+
38
+ case ":$PATH:" in
39
+ *":$BIN_DIR:"*) ;;
40
+ *) say "Add $BIN_DIR to your PATH: export PATH=\"$BIN_DIR:\$PATH\"" ;;
41
+ esac
42
+
43
+ say "Done. Try: builtindie top (or builtindie launch yourproduct.com)"
44
+ say "MCP: claude mcp add builtindie -- builtindie mcp"
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "built.indie",
3
+ "short_name": "built.indie",
4
+ "description": "Launch your indie product with one URL. Upvote what indies built today.",
5
+ "start_url": "/",
6
+ "scope": "/",
7
+ "display": "standalone",
8
+ "background_color": "#0b0a10",
9
+ "theme_color": "#0b0a10",
10
+ "categories": ["productivity", "business", "developer"],
11
+ "icons": [
12
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
13
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
14
+ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
15
+ ],
16
+ "shortcuts": [
17
+ { "name": "Launch", "url": "/submit" },
18
+ { "name": "Top today", "url": "/top/today" }
19
+ ]
20
+ }
@@ -0,0 +1,11 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBnDCCAUGgAwIBAgIQRGAxezez71rzl6e5/lPTRTAKBggqhkjOPQQDAjAsMRgw
3
+ FgYDVQQDEw9Nb3NocGl0IFJvb3QgQ0ExEDAOBgNVBAoTB01vc2hwaXQwHhcNMjYw
4
+ OTE2MTYyNjUzWhcNNDYwOTExMTYyNjUzWjAsMRgwFgYDVQQDEw9Nb3NocGl0IFJv
5
+ b3QgQ0ExEDAOBgNVBAoTB01vc2hwaXQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC
6
+ AAT04M/Vm4ypLIuYVqLhPhjp29v7CWwGzape9UE5RiDwNSApkCVJBBeRuu/xWCMI
7
+ pAn4BbwmozfCIwZkI+9XOHBTo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEBMA4GA1Ud
8
+ DwEB/wQEAwIBBjAdBgNVHQ4EFgQU5X5CZpvtc/QpAdNcfO2MSyCJu3EwCgYIKoZI
9
+ zj0EAwIDSQAwRgIhAOuKm14PMGs4qDiwIgSzzhNLQrs119RllEKjfFYEc6l5AiEA
10
+ 0wYe9QqkmywcXM6uGCYy5iAIMCZtXMVY1W0tOHxQssE=
11
+ -----END CERTIFICATE-----
@@ -0,0 +1,162 @@
1
+ :root {
2
+ --bg: #0b0a10; --panel: #15131d; --line: #262233; --text: #f1eef9; --muted: #9b95b0;
3
+ --accent: #ff5c39; --accent-ink: #fff; --ok: #39d98a; --warn: #f5b942; --fail: #ff6b6b; --gold: #f7c948;
4
+ color-scheme: dark;
5
+ }
6
+ @media (prefers-color-scheme: light) {
7
+ :root { --bg: #faf9fc; --panel: #fff; --line: #e7e3ef; --text: #16131f; --muted: #625c74; --accent: #e8431f; --accent-ink: #fff; --ok: #12a35c; --warn: #b7791f; --fail: #d93025; --gold: #b58105; color-scheme: light; }
8
+ }
9
+ * { box-sizing: border-box; }
10
+ html { -webkit-text-size-adjust: 100%; }
11
+ body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
12
+ a { color: var(--accent); text-decoration: none; }
13
+ a:hover { text-decoration: underline; }
14
+ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
15
+ code { font-size: .88em; background: var(--panel); border: 1px solid var(--line); border-radius: 5px; padding: .05em .35em; word-break: break-all; }
16
+ pre { font-size: .85em; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: .9rem 1rem; overflow-x: auto; white-space: pre; }
17
+ pre code { border: 0; padding: 0; background: none; word-break: normal; white-space: pre-wrap; }
18
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
19
+ .muted { color: var(--muted); }
20
+ .small { font-size: .85rem; }
21
+ .center { text-align: center; }
22
+ main { max-width: 820px; margin: 0 auto; padding: 0 16px 3rem; }
23
+ .top { max-width: 820px; margin: 0 auto; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; }
24
+ .brand { display: flex; align-items: center; gap: .45rem; color: var(--text); font-weight: 800; font-size: 1.2rem; letter-spacing: -.02em; }
25
+ .brand:hover { text-decoration: none; }
26
+ .brand b { color: var(--accent); }
27
+ .mark { width: 26px; height: 26px; color: var(--accent); }
28
+ .top nav { display: flex; gap: .9rem; flex-wrap: wrap; font-size: .92rem; align-items: center; }
29
+ .top nav a:not(.btn) { color: var(--muted); }
30
+ .btn { font: inherit; font-weight: 700; padding: .75rem 1.2rem; border: 0; border-radius: 10px; background: var(--accent); color: var(--accent-ink); cursor: pointer; display: inline-block; text-align: center; white-space: nowrap; }
31
+ .btn:hover { text-decoration: none; filter: brightness(1.08); }
32
+ .btn.ghost { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
33
+ .btn.wide { width: 100%; }
34
+ .small-btn { padding: .4rem .85rem; font-size: .9rem; }
35
+ .chip { font: inherit; font-size: .85rem; display: inline-block; padding: .35rem .75rem; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; white-space: nowrap; }
36
+ .chip:hover { border-color: var(--accent); text-decoration: none; }
37
+ .chip.danger { color: var(--fail); }
38
+ .linkish { font: inherit; background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
39
+ form.inline { display: inline; }
40
+ .hero { text-align: center; padding: 3.2rem 0 1.5rem; }
41
+ .hero.narrow { max-width: 460px; margin: 0 auto; text-align: left; }
42
+ .hero.narrow h1, .hero.narrow .lede { text-align: center; }
43
+ .hero h1 { font-size: clamp(2.2rem, 8vw, 3.6rem); line-height: 1.04; margin: 0 0 .7rem; letter-spacing: -.04em; }
44
+ .lede { color: var(--muted); font-size: 1.1rem; margin: 0 auto 1.4rem; max-width: 36rem; }
45
+ .page-h { font-size: 1.8rem; letter-spacing: -.02em; margin: 1.6rem 0 .6rem; }
46
+ .section-h { font-size: 1.1rem; margin: 2.2rem 0 .6rem; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; }
47
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 1.2rem; margin: 1rem 0; }
48
+ .card h2 { margin: 0 0 .6rem; font-size: 1.1rem; }
49
+ details.card > summary { cursor: pointer; font-weight: 700; }
50
+ details.card[open] > summary { margin-bottom: .6rem; }
51
+ .grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; }
52
+ .grid3 .card { margin: 0; }
53
+ .step-n { display: inline-grid; place-items: center; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--accent); color: var(--accent-ink); font-weight: 800; font-size: .85rem; margin-right: .4rem; }
54
+ label { display: block; font-weight: 600; margin: .9rem 0 .35rem; }
55
+ label .muted { font-weight: 400; }
56
+ input, select, textarea { width: 100%; font: inherit; padding: .7rem .85rem; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 10px; outline: none; }
57
+ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
58
+ [aria-invalid="true"] { border-color: var(--fail); }
59
+ .url-row { display: flex; gap: .5rem; }
60
+ .url-row input { flex: 1; min-width: 0; font-size: 1.05rem; }
61
+ .submit-form.compact { max-width: 560px; margin: 0 auto; }
62
+ .submit-form.busy button { opacity: .6; pointer-events: none; }
63
+ .busy-note { margin-top: .8rem; color: var(--accent); font-weight: 600; }
64
+ .busy-note::before { content: ""; display: inline-block; width: .8em; height: .8em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; margin-right: .5em; vertical-align: -.1em; animation: spin .8s linear infinite; }
65
+ @keyframes spin { to { transform: rotate(360deg); } }
66
+ details summary { cursor: pointer; font-weight: 600; margin-top: .8rem; }
67
+ .row { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; margin: .8rem 0; }
68
+ .row form { margin: 0; }
69
+ .note { border-left: 3px solid var(--accent); padding: .6rem .9rem; background: var(--panel); border-radius: 0 8px 8px 0; margin: 1rem 0; }
70
+ .note.error { border-left-color: var(--fail); }
71
+ .note.launched { border-left-color: var(--ok); }
72
+ .empty { color: var(--muted); padding: 1.5rem; text-align: center; border: 1px dashed var(--line); border-radius: 12px; }
73
+ .search { margin: .6rem 0; }
74
+ .tabs { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0; }
75
+ .tabs a { padding: .3rem .8rem; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font-size: .9rem; }
76
+ .tabs a[aria-current] { border-color: var(--accent); color: var(--text); }
77
+ /* the board */
78
+ .board { list-style: none; padding: 0; margin: 0; }
79
+ .row-item { display: flex; align-items: center; gap: .8rem; padding: .7rem .5rem; border-bottom: 1px solid var(--line); }
80
+ .row-item:last-child { border-bottom: 0; }
81
+ .rank { width: 1.6rem; text-align: right; color: var(--muted); font-weight: 700; flex: none; }
82
+ .row-main { display: flex; align-items: center; gap: .85rem; flex: 1; min-width: 0; color: var(--text); }
83
+ .row-main:hover { text-decoration: none; }
84
+ .row-main:hover b { color: var(--accent); }
85
+ .row-text { display: flex; flex-direction: column; min-width: 0; }
86
+ .row-text .muted { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .92rem; }
87
+ .logo { border-radius: 12px; object-fit: contain; background: #fff1; flex: none; }
88
+ .logo-letter { display: grid; place-items: center; background: var(--accent); color: var(--accent-ink); font-weight: 800; font-size: 1.3rem; }
89
+ .tick { color: var(--ok); font-weight: 800; }
90
+ .tags { display: flex; gap: .3rem; flex-wrap: wrap; margin-top: .2rem; }
91
+ .tag { font-size: .75rem; padding: .05rem .5rem; border-radius: 999px; background: var(--line); color: var(--muted); }
92
+ a.tag:hover { color: var(--text); text-decoration: none; }
93
+ .vote-form { margin: 0; flex: none; }
94
+ .vote { font: inherit; display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 3.4rem; height: 3.4rem; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; line-height: 1.1; }
95
+ .vote:hover { border-color: var(--accent); }
96
+ .vote .arrow { color: var(--accent); font-size: .85rem; }
97
+ .vote .count { font-weight: 800; }
98
+ .vote.voted { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
99
+ .vote.voted .arrow { color: var(--accent-ink); }
100
+ .vote.big { min-width: 5rem; height: 4.2rem; font-size: 1.15rem; }
101
+ .vote.bump { animation: bump .35s ease; }
102
+ @keyframes bump { 50% { transform: scale(1.15); } }
103
+ /* a product */
104
+ .product-head { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-top: 1.4rem; }
105
+ .product-title { flex: 1; min-width: 220px; }
106
+ .product-title h1 { margin: 0; font-size: 2rem; letter-spacing: -.02em; line-height: 1.1; }
107
+ .product-title .lede { margin: .3rem 0 .4rem; text-align: left; }
108
+ .product-actions { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
109
+ .badges { display: flex; gap: .35rem; flex-wrap: wrap; margin: 0; }
110
+ .pill { font-size: .78rem; padding: .15rem .6rem; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
111
+ .pill.ok { color: var(--ok); border-color: var(--ok); }
112
+ .pill.gold { color: var(--gold); border-color: var(--gold); font-weight: 700; }
113
+ .gallery { display: flex; gap: .7rem; overflow-x: auto; scroll-snap-type: x mandatory; margin: 1.2rem 0; padding-bottom: .4rem; }
114
+ .gallery a { flex: none; scroll-snap-align: start; }
115
+ .gallery img { display: block; height: 260px; width: auto; max-width: 85vw; object-fit: cover; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); }
116
+ .videos { display: grid; gap: .8rem; margin: 1rem 0; }
117
+ .video { position: relative; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); }
118
+ .video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
119
+ .desc { white-space: pre-line; margin-top: 0; }
120
+ .socials, .share { display: flex; gap: .4rem; flex-wrap: wrap; }
121
+ .maker { margin: .8rem 0 .2rem; }
122
+ .address { display: flex; gap: .5rem; align-items: center; margin: .3rem 0 .6rem; }
123
+ .address code { flex: 1; padding: .55rem .7rem; }
124
+ .copy { font: inherit; font-size: .85rem; padding: .5rem .8rem; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--text); cursor: pointer; white-space: nowrap; }
125
+ .pics { border: 1px solid var(--line); border-radius: 12px; padding: .8rem; margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .7rem; }
126
+ .pics legend { font-weight: 600; padding: 0 .3rem; }
127
+ .pic { margin: 0; font-weight: 400; font-size: .85rem; }
128
+ .pic img { width: 100%; height: 90px; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: .3rem; background: var(--bg); }
129
+ .pic input { width: auto; }
130
+ .payee { text-align: center; padding: 2rem 0 .5rem; }
131
+ .ad-unit { margin: 24px auto; display: flex; justify-content: center; }
132
+ .ad-unit iframe { border: 0; display: block; background: transparent; }
133
+ .ad-text_link { max-width: 820px; padding: 0 16px; }
134
+ .ad-text_link iframe { width: 100%; height: 40px; }
135
+ .ad-banner_300x250 iframe { width: 300px; height: 250px; }
136
+ .ad-banner_728x90 iframe { width: 728px; max-width: 100%; height: 90px; }
137
+ @media (max-width: 760px) { .ad-banner_728x90 { display: none; } }
138
+ @media (max-width: 340px) { .ad-banner_300x250 { margin-inline: calc(50% - 50vw); } }
139
+ footer { max-width: 820px; margin: 2rem auto 0; padding: 1.5rem 16px 2.5rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .88rem; display: flex; gap: 1rem; flex-wrap: wrap; justify-content: space-between; }
140
+ footer a { color: var(--muted); }
141
+ @media (max-width: 560px) {
142
+ .url-row { flex-direction: column; }
143
+ .gallery img { height: 190px; }
144
+ .product-title h1 { font-size: 1.6rem; }
145
+ .row-item { gap: .55rem; }
146
+ }
147
+ /* the Moshpit ring bar (pit.world/ring), on every page */
148
+ .ring-bar { display: flex; align-items: center; gap: 4px 16px; padding: 6px 16px; border-bottom: 1px solid var(--line); background: var(--panel); font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; overflow-x: auto; scrollbar-width: none; }
149
+ .ring-bar::-webkit-scrollbar { display: none; }
150
+ .ring-bar a { color: var(--muted); }
151
+ .ring-bar a:hover, .ring-bar a:focus-visible { color: var(--accent); text-decoration: underline; }
152
+ .ring-bar .ring-home { color: var(--accent); font-weight: 700; }
153
+ .ring-bar .ring-sites { display: inline-flex; gap: 12px; padding: 0 12px; border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
154
+ .ring-bar [aria-current='page'] { color: var(--text); }
155
+ @media (max-width: 640px) { .ring-bar .ring-sites, .ring-bar .ring-home span { display: none; } }
156
+ /* OpenIcon icons: inline SVG in currentColor */
157
+ .i { width: 1em; height: 1em; vertical-align: -.125em; flex: none; }
158
+ .chip .i, .btn .i { margin-right: .15em; }
159
+ .vote .arrow .i { width: .95em; height: .95em; }
160
+ .mini-h { font-size: .95rem; margin: 1.1rem 0 .4rem; display: flex; gap: .4rem; align-items: center; }
161
+ .stack p { margin: .25rem 0; }
162
+ .plans { list-style: none; padding: 0; margin: 0; display: grid; gap: .25rem; }
package/public/sw.js ADDED
@@ -0,0 +1,30 @@
1
+ // built.indie service worker: installable, and the board still opens offline.
2
+ // Pages: network first, the last copy when offline. Static files and pictures: cache first.
3
+ const VERSION = 'bi-1'
4
+ const SHELL = ['/', '/style.css', '/app.js', '/favicon.svg', '/icon-192.png']
5
+
6
+ self.addEventListener('install', (e) => {
7
+ e.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()))
8
+ })
9
+ self.addEventListener('activate', (e) => {
10
+ e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim()))
11
+ })
12
+ self.addEventListener('fetch', (e) => {
13
+ const req = e.request
14
+ const url = new URL(req.url)
15
+ if (req.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth/') || url.pathname.startsWith('/badge/')) return
16
+ const asset = /\.(css|js|svg|png|jpg|webp|gif|avif|ico)$/.test(url.pathname) || url.pathname.startsWith('/m/')
17
+ if (asset) {
18
+ e.respondWith(caches.match(req).then((hit) => hit || fetch(req).then((res) => {
19
+ if (res.ok) { const copy = res.clone(); caches.open(VERSION).then((c) => c.put(req, copy)) }
20
+ return res
21
+ })))
22
+ return
23
+ }
24
+ if (req.mode === 'navigate') {
25
+ e.respondWith(fetch(req).then((res) => {
26
+ if (res.ok && !url.pathname.match(/^\/(me|login|p\/[^/]+\/(edit|claim))/)) { const copy = res.clone(); caches.open(VERSION).then((c) => c.put(req, copy)) }
27
+ return res
28
+ }).catch(() => caches.match(req).then((hit) => hit || caches.match('/'))))
29
+ }
30
+ })
package/src/auth.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Who is asking: an emailed magic link (which is also sign-up: an unknown
3
+ * address makes the account), a session cookie, or an account API key (bi_…)
4
+ * for the CLI, TUI and MCP server.
5
+ *
6
+ * Every secret is stored as its SHA-256 only. The sign-in form never says
7
+ * whether an address has an account. Passkeys are the house's second way in;
8
+ * they are left out here because a passkey is bound to one hostname and most
9
+ * visitors reach built.indie through the pit.world relay.
10
+ */
11
+ import { randomBytes } from 'node:crypto'
12
+ import { hashKey, type Store, type User } from './db.js'
13
+
14
+ export const SESSION_COOKIE = 'bi_session'
15
+ const SESSION_DAYS = 90
16
+ const LINK_MINUTES = 20
17
+
18
+ export type Mail = { to: string; subject: string; text: string; html: string }
19
+ export type Mailer = (mail: Mail) => Promise<unknown>
20
+
21
+ export function resendMailer(apiKey: string, from: string, fetchImpl: typeof fetch = fetch): Mailer {
22
+ return async ({ to, subject, text, html }) => {
23
+ const res = await fetchImpl('https://api.resend.com/emails', {
24
+ method: 'POST',
25
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
26
+ body: JSON.stringify({ from, to: [to], subject, text, html }),
27
+ signal: AbortSignal.timeout(15_000),
28
+ })
29
+ if (!res.ok) throw new Error(`mail provider answered ${res.status}: ${(await res.text()).slice(0, 200)}`)
30
+ }
31
+ }
32
+
33
+ /** Without a mail key (local dev) the link goes to the log instead. */
34
+ export const consoleMailer: Mailer = async ({ to, text }) => console.log(`[mail] to ${to}\n${text}`)
35
+
36
+ export const validEmail = (value: unknown): string | null => {
37
+ const email = String(value ?? '').trim().toLowerCase()
38
+ return email.length <= 254 && /^[^\s@<>()",;]+@[^\s@<>()",;]+\.[a-z0-9-]{2,}$/i.test(email) ? email : null
39
+ }
40
+
41
+ /** Only same-site paths survive a redirect, so a link can never bounce someone off-site. */
42
+ export const safeNext = (value: unknown): string | null => {
43
+ const next = String(value ?? '')
44
+ return next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') && next.length < 500 ? next : null
45
+ }
46
+
47
+ export class Auth {
48
+ constructor(private store: Store, private mailer: Mailer, private now = () => Date.now()) {}
49
+
50
+ /** Always resolves the same way, so nobody learns who has an account. */
51
+ async sendLink(emailInput: string, next: string | null, base: string): Promise<boolean> {
52
+ const email = validEmail(emailInput)
53
+ if (!email) return false
54
+ const db = this.store.db
55
+ const recent = (db.query('SELECT COUNT(*) AS n FROM magic_links WHERE email = ? AND created_at > ?').get(email, this.now() - 15 * 60_000) as { n: number }).n
56
+ if (recent >= 5) return true
57
+ const token = randomBytes(24).toString('base64url')
58
+ db.query('INSERT INTO magic_links (token_hash, email, next, created_at, expires_at) VALUES (?, ?, ?, ?, ?)').run(hashKey(token), email, safeNext(next), this.now(), this.now() + LINK_MINUTES * 60_000)
59
+ const url = `${base}/auth/verify?token=${token}`
60
+ const text = `Sign in to built.indie:\n\n${url}\n\nThe link works once, for ${LINK_MINUTES} minutes. If you did not ask for it, ignore this email.`
61
+ const html = `<p>Sign in to <strong>built.indie</strong>:</p><p><a href="${url}" style="display:inline-block;padding:10px 18px;background:#ff5c39;color:#fff;border-radius:8px;text-decoration:none;font-weight:700">Sign in</a></p><p style="color:#666;font-size:13px">Or open ${url}<br>The link works once, for ${LINK_MINUTES} minutes. If you did not ask for it, ignore this email.</p>`
62
+ try {
63
+ await this.mailer({ to: email, subject: 'Your built.indie sign-in link', text, html })
64
+ } catch (error) {
65
+ console.error(`[auth] mail failed: ${(error as Error).message}`)
66
+ }
67
+ return true
68
+ }
69
+
70
+ /** One use, then it is gone. Returns the user (made on first sign-in) and where to go. */
71
+ consumeLink(token: string): { user: User; next: string | null } | null {
72
+ const hash = hashKey(String(token))
73
+ const db = this.store.db
74
+ const row = db.query('SELECT email, next, expires_at FROM magic_links WHERE token_hash = ?').get(hash) as { email: string; next: string | null; expires_at: number } | null
75
+ db.query('DELETE FROM magic_links WHERE token_hash = ? OR expires_at < ?').run(hash, this.now())
76
+ if (!row || row.expires_at < this.now()) return null
77
+ return { user: this.store.findOrCreateUser(row.email), next: row.next }
78
+ }
79
+
80
+ createSession(userId: string): string {
81
+ const token = randomBytes(24).toString('base64url')
82
+ this.store.db.query('INSERT INTO sessions (id_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(hashKey(token), userId, this.now(), this.now() + SESSION_DAYS * 86_400_000)
83
+ return token
84
+ }
85
+
86
+ sessionUser(token: string | undefined | null): User | null {
87
+ if (!token) return null
88
+ const row = this.store.db.query('SELECT user_id, expires_at FROM sessions WHERE id_hash = ?').get(hashKey(token)) as { user_id: string; expires_at: number } | null
89
+ if (!row || row.expires_at < this.now()) return null
90
+ return this.store.user(row.user_id)
91
+ }
92
+
93
+ endSession(token: string | undefined | null): void {
94
+ if (token) this.store.db.query('DELETE FROM sessions WHERE id_hash = ?').run(hashKey(token))
95
+ }
96
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * builtindie: built.indie from the terminal.
3
+ *
4
+ * builtindie the TUI: today's board, vote, launch
5
+ * builtindie launch <url> launch it: free, live at once
6
+ * builtindie top [today|week|month|all|new]
7
+ * builtindie show <slug>
8
+ * builtindie vote <slug>
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
12
+ * builtindie me your launches, BUILTINDIE_KEY
13
+ * builtindie edit <slug> --tagline … (BUILTINDIE_KEY)
14
+ * builtindie refetch <slug> (BUILTINDIE_KEY)
15
+ * builtindie mcp MCP server on stdio
16
+ * builtindie serve [--port N] run your own built.indie
17
+ */
18
+ import { BuiltIndieClient, type ApiProduct } from './client.js'
19
+ import { VERSION } from './version.js'
20
+
21
+ export const HELP = `builtindie ${VERSION}: launch your indie product with one URL (https://built.indie.pit.world)
22
+
23
+ builtindie interactive (TUI)
24
+ builtindie launch <url> launch it, free; we fetch logo, screenshots, videos, llms.txt
25
+ [--maker "Name"] [--profile <openprofile.md url>] [--tagline "…"] [--draft]
26
+ builtindie top [today|week|month|all|new|featured]
27
+ builtindie show <slug>
28
+ builtindie vote <slug>
29
+ 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
33
+ builtindie me your launches
34
+ builtindie edit <slug> [--name --tagline --description --tags a,b --video URL]
35
+ builtindie refetch <slug> read the homepage again
36
+ builtindie publish <slug> put a draft live
37
+ builtindie verify <slug> check the badge is on your homepage
38
+ builtindie mcp MCP server: claude mcp add builtindie -- bunx @profullstack/builtindie mcp
39
+ builtindie serve [--port N] run your own instance
40
+
41
+ --json machine-readable output
42
+ BUILTINDIE_KEY=bi_… your API key from built.indie.pit.world/me (claim, edit, me)
43
+ BUILTINDIE_URL=https://… another instance (default https://built.indie.pit.world)
44
+ `
45
+
46
+ type Out = { log(text: string): void; error(text: string): void }
47
+
48
+ /** --name value pairs and bare words, in order. */
49
+ export function parseArgs(argv: string[]): { words: string[]; flags: Record<string, string | true> } {
50
+ const words: string[] = []
51
+ const flags: Record<string, string | true> = {}
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i]!
54
+ if (a.startsWith('--')) {
55
+ const [k, v] = a.slice(2).split(/=(.*)/s) as [string, string | undefined]
56
+ 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]!
58
+ else flags[k] = true
59
+ } else words.push(a)
60
+ }
61
+ return { words, flags }
62
+ }
63
+
64
+ export const line = (p: ApiProduct, rank?: number) => `${rank ? `${String(rank).padStart(2)}. ` : ''}▲${String(p.votes).padStart(4)} ${p.name}${p.verified ? ' ✓' : ''}: ${p.tagline || p.domain} ${p.page}`
65
+
66
+ export function describe(p: ApiProduct): string {
67
+ return [
68
+ `${p.name}${p.verified ? ' ✓' : ''} ▲ ${p.votes}${p.award ? ` 🏆 #${p.award.rank} of ${p.award.day}` : ''}`,
69
+ p.tagline,
70
+ '',
71
+ p.description,
72
+ '',
73
+ `site ${p.url}`,
74
+ `page ${p.page}`,
75
+ p.tags.length ? `tags ${p.tags.join(', ')}` : '',
76
+ p.logo ? `logo ${p.logo}` : '',
77
+ [p.screenshot, ...p.images].filter(Boolean).length ? `pictures ${[p.screenshot, p.card_image, ...p.images].filter(Boolean).join('\n ')}` : '',
78
+ p.videos.length ? `videos ${p.videos.map((v) => v.url).join('\n ')}` : '',
79
+ Object.keys(p.socials).length ? `socials ${Object.entries(p.socials).map(([k, v]) => `${k}: ${v}`).join('\n ')}` : '',
80
+ p.llms ? `llms.txt ${p.llms.url}` : '',
81
+ p.profile ? `profile ${p.profile.url} (${p.profile.kind ?? 'openprofile'})` : '',
82
+ p.maker ? `maker ${p.maker.name}${p.maker.verified ? ' ✓' : ''}${p.maker.profile ? ` ${p.maker.profile}` : ''}` : '',
83
+ `badge ${p.badge}`,
84
+ ]
85
+ .filter((l, i, all) => l !== '' || (all[i - 1] !== '' && i > 0))
86
+ .join('\n')
87
+ }
88
+
89
+ export async function main(argv = process.argv.slice(2), out: Out = console, client = new BuiltIndieClient()): Promise<number> {
90
+ const { words, flags } = parseArgs(argv)
91
+ const json = Boolean(flags.json)
92
+ const [command, ...rest] = words
93
+ const show = (value: unknown, text: string) => out.log(json ? JSON.stringify(value, null, 2) : text)
94
+ const str = (k: string) => (typeof flags[k] === 'string' ? (flags[k] as string) : undefined)
95
+ try {
96
+ if (!command) {
97
+ if (!process.stdout.isTTY) return out.log(HELP), 0
98
+ const { runTui } = await import('./tui.js')
99
+ await runTui(client)
100
+ return 0
101
+ }
102
+ switch (command) {
103
+ case 'help':
104
+ case '--help':
105
+ out.log(HELP)
106
+ return 0
107
+ case 'version':
108
+ out.log(VERSION)
109
+ return 0
110
+ case 'mcp': {
111
+ const { runMcp } = await import('./mcp.js')
112
+ await runMcp(client)
113
+ return 0
114
+ }
115
+ case 'serve': {
116
+ const { serve } = await import('./server.js')
117
+ const s = serve(str('port') ? { port: Number(str('port')) } : {})
118
+ out.log(`built.indie listening on http://${s.hostname}:${s.port}`)
119
+ return await new Promise<number>(() => {})
120
+ }
121
+ case 'launch':
122
+ case 'submit': {
123
+ if (!rest[0]) throw new Error('builtindie launch <url>')
124
+ const r = await client.launch(rest[0], { makerName: str('maker'), makerProfile: str('profile'), name: str('name'), tagline: str('tagline'), description: str('description'), tags: str('tags')?.split(','), videos: str('video') ? [str('video')!] : undefined, publish: !flags.draft })
125
+ if (r.existing) return show(r, `Already launched:\n${line(r.product)}`), 0
126
+ show(r, `${describe(r.product)}\n\n${r.product.status === 'live' ? 'Live. Share it and add the badge.' : `Draft: builtindie publish ${r.product.slug}`}\nYours? builtindie claim ${r.product.slug} (${r.claim_url})`)
127
+ return 0
128
+ }
129
+ case 'top':
130
+ case 'list':
131
+ case 'new': {
132
+ const w = command === 'new' ? 'new' : (rest[0] ?? 'today')
133
+ const r = await client.list(w, Number(str('limit') ?? 30))
134
+ show(r, r.products.map((p, i) => line(p, w === 'new' ? undefined : i + 1)).join('\n') || `Nothing launched ${w === 'today' ? 'today yet' : 'here yet'}.`)
135
+ return 0
136
+ }
137
+ case 'search': {
138
+ const r = await client.search(rest.join(' '))
139
+ show(r, r.products.map((p) => line(p)).join('\n') || 'Nothing matches.')
140
+ return 0
141
+ }
142
+ case 'show': {
143
+ const p = await client.product(rest[0] ?? '')
144
+ show(p, describe(p))
145
+ return 0
146
+ }
147
+ case 'vote': {
148
+ const r = await client.vote(rest[0] ?? '')
149
+ show(r, r.counted ? `▲ ${r.votes}: thanks!` : `▲ ${r.votes}: you already voted.`)
150
+ return 0
151
+ }
152
+ case 'preview': {
153
+ const r = await client.preview(rest[0] ?? '')
154
+ show(r, JSON.stringify(r, null, 2))
155
+ return 0
156
+ }
157
+ case 'edit': {
158
+ const patch: Record<string, unknown> = {}
159
+ for (const k of ['name', 'tagline', 'description']) if (str(k) !== undefined) patch[k] = str(k)
160
+ if (str('tags') !== undefined) patch.tags = str('tags')!.split(',')
161
+ if (str('video') !== undefined) patch.videos = [str('video')]
162
+ if (str('maker') !== undefined) patch.maker_name = str('maker')
163
+ if (str('profile') !== undefined) patch.maker_profile = str('profile')
164
+ const p = await client.edit(rest[0] ?? '', patch, str('key'))
165
+ show(p, describe(p))
166
+ return 0
167
+ }
168
+ case 'refetch':
169
+ case 'publish': {
170
+ const p = await client.act(rest[0] ?? '', command, str('key'))
171
+ show(p, describe(p))
172
+ return 0
173
+ }
174
+ case 'claim': {
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
+ }
181
+ 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(', ')})`)
184
+ return 1
185
+ }
186
+ case 'me': {
187
+ const r = await client.me()
188
+ 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
+ return 0
190
+ }
191
+ case 'verify': {
192
+ const r = await client.verify(rest[0] ?? '')
193
+ show(r, r.verified ? 'Verified ✓: we found the badge.' : 'Not found yet: add the badge HTML to your homepage and deploy.')
194
+ return r.verified ? 0 : 1
195
+ }
196
+ }
197
+ out.error(`builtindie: unknown command "${command}"\n\n${HELP}`)
198
+ return 2
199
+ } catch (error) {
200
+ out.error(error instanceof Error ? error.message : String(error))
201
+ return 1
202
+ }
203
+ }