@profullstack/builtindie 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/OpenStack.md +74 -0
- package/README.md +63 -2
- package/bin/builtindie +3 -0
- package/package.json +35 -4
- package/public/app.js +35 -0
- package/public/favicon.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/openicon.json +574 -0
- package/public/icons/svg/apple.svg +1 -0
- package/public/icons/svg/arrow-up.svg +1 -0
- package/public/icons/svg/bluesky.svg +1 -0
- package/public/icons/svg/check.svg +1 -0
- package/public/icons/svg/chrome.svg +1 -0
- package/public/icons/svg/code.svg +1 -0
- package/public/icons/svg/discord.svg +1 -0
- package/public/icons/svg/dollar.svg +1 -0
- package/public/icons/svg/external-link.svg +1 -0
- package/public/icons/svg/github.svg +1 -0
- package/public/icons/svg/globe.svg +1 -0
- package/public/icons/svg/instagram.svg +1 -0
- package/public/icons/svg/layers.svg +1 -0
- package/public/icons/svg/link.svg +1 -0
- package/public/icons/svg/linkedin.svg +1 -0
- package/public/icons/svg/mail.svg +1 -0
- package/public/icons/svg/product-hunt.svg +1 -0
- package/public/icons/svg/reddit.svg +1 -0
- package/public/icons/svg/rocket.svg +1 -0
- package/public/icons/svg/search.svg +1 -0
- package/public/icons/svg/share.svg +1 -0
- package/public/icons/svg/star.svg +1 -0
- package/public/icons/svg/tag.svg +1 -0
- package/public/icons/svg/telegram.svg +1 -0
- package/public/icons/svg/terminal.svg +1 -0
- package/public/icons/svg/tiktok.svg +1 -0
- package/public/icons/svg/trophy.svg +1 -0
- package/public/icons/svg/user.svg +1 -0
- package/public/icons/svg/x.svg +1 -0
- package/public/icons/svg/youtube.svg +1 -0
- package/public/install.sh +44 -0
- package/public/manifest.webmanifest +20 -0
- package/public/moshpit-root-ca.crt +11 -0
- package/public/style.css +162 -0
- package/public/sw.js +30 -0
- package/src/auth.ts +96 -0
- package/src/cli.ts +203 -0
- package/src/client.ts +115 -0
- package/src/db.ts +412 -0
- package/src/fetch.ts +708 -0
- package/src/html.ts +525 -0
- package/src/icons.ts +56 -0
- package/src/mcp.ts +79 -0
- package/src/media.ts +160 -0
- package/src/ring.ts +86 -0
- package/src/server.ts +655 -0
- package/src/service.ts +369 -0
- package/src/tui.ts +127 -0
- package/src/version.ts +1 -0
|
@@ -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-----
|
package/public/style.css
ADDED
|
@@ -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
|
+
}
|