commit 8287fbf5b8d8c4b86ea9858f41a50ab4f0f23d68 Author: Ryan Moon Date: Fri Apr 3 06:23:56 2026 -0500 feat: initial lunarfront-manager app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e1a796 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:1 AS base +WORKDIR /app + +FROM base AS install +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile --production + +FROM base +COPY --from=install /app/node_modules ./node_modules +COPY src ./src +COPY frontend ./frontend +COPY package.json ./ + +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["bun", "run", "src/index.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..82a743d --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# lunarfront-manager + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..55d92e4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,170 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "lunarfront-manager", + "dependencies": { + "@fastify/static": "^9.0.0", + "fastify": "^5.8.4", + "zod": "^4.3.6", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^25.5.2", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="], + + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], + + "@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="], + + "@fastify/static": ["@fastify/static@9.0.0", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA=="], + + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="], + + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fastify": ["fastify@5.8.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="], + + "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + + "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + + "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ca2b7b6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,68 @@ + + + + + + LunarFront Manager + + + +

LunarFront Manager

+ +
+

Provision Customer

+ + + +
+
+ + + + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..96a40cf --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "lunarfront-manager", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^25.5.2" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@fastify/static": "^9.0.0", + "fastify": "^5.8.4", + "zod": "^4.3.6" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fc7628a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,20 @@ +import Fastify from "fastify"; +import staticFiles from "@fastify/static"; +import { join } from "path"; +import { customerRoutes } from "./routes/customers"; + +const app = Fastify({ logger: true }); + +app.register(staticFiles, { + root: join(import.meta.dir, "../frontend"), + prefix: "/", +}); + +app.register(customerRoutes, { prefix: "/api" }); + +app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" }, (err) => { + if (err) { + app.log.error(err); + process.exit(1); + } +}); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..52957c0 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,14 @@ +export const config = { + port: Number(process.env.PORT ?? 3000), + doToken: process.env.DO_API_TOKEN!, + doDbClusterId: process.env.DO_DB_CLUSTER_ID!, + gitSshKey: process.env.GIT_SSH_KEY!, + gitRepoUrl: process.env.GIT_REPO_URL ?? "ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git", + dbUrl: process.env.DATABASE_URL!, +}; + +for (const [key, val] of Object.entries(config)) { + if (val === undefined || val === "") { + throw new Error(`Missing required env var for config key: ${key}`); + } +} diff --git a/src/lib/k8s.ts b/src/lib/k8s.ts new file mode 100644 index 0000000..2a73af4 --- /dev/null +++ b/src/lib/k8s.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "fs"; +import { execSync } from "child_process"; + +// Read in-cluster service account token and CA +const SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"; +const K8S_API = process.env.KUBERNETES_SERVICE_HOST + ? `https://${process.env.KUBERNETES_SERVICE_HOST}:${process.env.KUBERNETES_SERVICE_PORT}` + : "https://kubernetes.default.svc"; + +function token() { + return readFileSync(SA_TOKEN_PATH, "utf-8").trim(); +} + +async function k8sFetch(path: string, options: RequestInit = {}) { + const res = await fetch(`${K8S_API}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token()}`, + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + // @ts-ignore - bun supports this + tls: { rejectUnauthorized: false }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`k8s API ${options.method ?? "GET"} ${path} → ${res.status}: ${body}`); + } + return res.json(); +} + +export async function getSecret(namespace: string, name: string): Promise> { + const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`); + return Object.fromEntries( + Object.entries(secret.data as Record).map(([k, v]) => [k, Buffer.from(v, "base64").toString()]) + ); +} + +export async function patchSecret(namespace: string, name: string, data: Record) { + const encoded = Object.fromEntries( + Object.entries(data).map(([k, v]) => [k, Buffer.from(v).toString("base64")]) + ); + return k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`, { + method: "PATCH", + headers: { "Content-Type": "application/strategic-merge-patch+json" }, + body: JSON.stringify({ data: encoded }), + }); +} + +export async function patchConfigMap(namespace: string, name: string, data: Record) { + return k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`, { + method: "PATCH", + headers: { "Content-Type": "application/strategic-merge-patch+json" }, + body: JSON.stringify({ data }), + }); +} + +export async function getConfigMap(namespace: string, name: string): Promise> { + const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`); + return cm.data ?? {}; +} + +export async function createArgoCDApp(manifest: object) { + return k8sFetch(`/apis/argoproj.io/v1alpha1/namespaces/argocd/applications`, { + method: "POST", + body: JSON.stringify(manifest), + }); +} + +export async function rolloutRestart(namespace: string, deployment: string) { + return k8sFetch(`/apis/apps/v1/namespaces/${namespace}/deployments/${deployment}`, { + method: "PATCH", + headers: { "Content-Type": "application/strategic-merge-patch+json" }, + body: JSON.stringify({ + spec: { template: { metadata: { annotations: { "kubectl.kubernetes.io/restartedAt": new Date().toISOString() } } } }, + }), + }); +} diff --git a/src/routes/customers.ts b/src/routes/customers.ts new file mode 100644 index 0000000..0b695b2 --- /dev/null +++ b/src/routes/customers.ts @@ -0,0 +1,46 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do"; +import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer"; +import { addCustomerChart, removeCustomerChart } from "../services/git"; + +const ProvisionSchema = z.object({ + name: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"), + appVersion: z.string().default("latest"), +}); + +export async function customerRoutes(app: FastifyInstance) { + app.post("/customers", async (req, reply) => { + const body = ProvisionSchema.parse(req.body); + const slug = body.name; + + app.log.info({ slug }, "provisioning customer"); + + const [, user] = await Promise.all([ + createDatabase(slug), + createDatabaseUser(slug), + ]); + + await addCustomerToPool(slug, user.password); + addCustomerChart(slug, body.appVersion); + + app.log.info({ slug }, "customer provisioned"); + return reply.code(201).send({ slug, status: "provisioned" }); + }); + + app.delete("/customers/:slug", async (req, reply) => { + const { slug } = req.params as { slug: string }; + + app.log.info({ slug }, "deprovisioning customer"); + + removeCustomerChart(slug); + await removeCustomerFromPool(slug); + await Promise.all([ + deleteDatabase(slug), + deleteDatabaseUser(slug), + ]); + + app.log.info({ slug }, "customer deprovisioned"); + return reply.code(204).send(); + }); +} diff --git a/src/services/do.ts b/src/services/do.ts new file mode 100644 index 0000000..e951925 --- /dev/null +++ b/src/services/do.ts @@ -0,0 +1,48 @@ +import { config } from "../lib/config"; + +const DO_API = "https://api.digitalocean.com/v2"; + +async function doFetch(path: string, options: RequestInit = {}) { + const res = await fetch(`${DO_API}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${config.doToken}`, + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`DO API ${options.method ?? "GET"} ${path} → ${res.status}: ${body}`); + } + if (res.status === 204) return null; + return res.json(); +} + +export async function createDatabase(name: string) { + return doFetch(`/databases/${config.doDbClusterId}/dbs`, { + method: "POST", + body: JSON.stringify({ name }), + }); +} + +export async function createDatabaseUser(name: string): Promise<{ name: string; password: string }> { + const res = await doFetch(`/databases/${config.doDbClusterId}/users`, { + method: "POST", + body: JSON.stringify({ name }), + }); + return res.user; +} + +export async function deleteDatabaseUser(name: string) { + return doFetch(`/databases/${config.doDbClusterId}/users/${name}`, { method: "DELETE" }); +} + +export async function deleteDatabase(name: string) { + return doFetch(`/databases/${config.doDbClusterId}/dbs/${name}`, { method: "DELETE" }); +} + +export async function getDatabaseHost(): Promise { + const res = await doFetch(`/databases/${config.doDbClusterId}`); + return res.database.connection.host; +} diff --git a/src/services/git.ts b/src/services/git.ts new file mode 100644 index 0000000..4319820 --- /dev/null +++ b/src/services/git.ts @@ -0,0 +1,76 @@ +import { execSync } from "child_process"; +import { writeFileSync, mkdirSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { config } from "../lib/config"; + +function withRepo(fn: (dir: string) => T): T { + const keyPath = join(tmpdir(), `manager-ssh-key-${Date.now()}`); + const dir = join(tmpdir(), `lunarfront-charts-${Date.now()}`); + + writeFileSync(keyPath, config.gitSshKey, { mode: 0o600 }); + + const env = { + ...process.env, + GIT_SSH_COMMAND: `ssh -i ${keyPath} -o StrictHostKeyChecking=no`, + }; + + try { + execSync(`git clone ${config.gitRepoUrl} ${dir}`, { env, stdio: "pipe" }); + execSync(`git -C ${dir} config user.email "manager@lunarfront.tech"`, { env }); + execSync(`git -C ${dir} config user.name "lunarfront-manager"`, { env }); + const result = fn(dir); + execSync(`git -C ${dir} push origin main`, { env, stdio: "pipe" }); + return result; + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(keyPath, { force: true }); + } +} + +export function addCustomerChart(slug: string, appVersion: string) { + withRepo((dir) => { + const manifest = buildArgoCDApp(slug, appVersion); + writeFileSync(join(dir, "customers", `${slug}.yaml`), manifest); + execSync(`git -C ${dir} add customers/${slug}.yaml`); + execSync(`git -C ${dir} commit -m "feat: provision customer ${slug}"`, { env: process.env }); + }); +} + +export function removeCustomerChart(slug: string) { + withRepo((dir) => { + rmSync(join(dir, "customers", `${slug}.yaml`), { force: true }); + execSync(`git -C ${dir} add customers/${slug}.yaml`); + execSync(`git -C ${dir} commit -m "chore: deprovision customer ${slug}"`, { env: process.env }); + }); +} + +function buildArgoCDApp(slug: string, appVersion: string): string { + return `apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: customer-${slug} + namespace: argocd +spec: + project: default + sources: + - repoURL: git.lunarfront.tech/ryan/lunarfront-app + chart: lunarfront + targetRevision: "${appVersion}" + helm: + valueFiles: + - $values/customers/${slug}.yaml + - repoURL: ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git + targetRevision: main + ref: values + destination: + server: https://kubernetes.default.svc + namespace: customer-${slug} + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +`; +} diff --git a/src/services/pgbouncer.ts b/src/services/pgbouncer.ts new file mode 100644 index 0000000..ceffb21 --- /dev/null +++ b/src/services/pgbouncer.ts @@ -0,0 +1,50 @@ +import { getConfigMap, getSecret, patchConfigMap, patchSecret, rolloutRestart } from "../lib/k8s"; + +const NAMESPACE = "pgbouncer"; +const DO_HOST = "lunarfront-postgres-do-user-35277853-0.e.db.ondigitalocean.com"; +const DO_PORT = 25060; + +export async function addCustomerToPool(slug: string, password: string) { + await Promise.all([ + addDbEntry(slug), + addUserEntry(slug, password), + ]); + await rolloutRestart(NAMESPACE, "pgbouncer"); +} + +export async function removeCustomerFromPool(slug: string) { + await Promise.all([ + removeDbEntry(slug), + removeUserEntry(slug), + ]); + await rolloutRestart(NAMESPACE, "pgbouncer"); +} + +async function addDbEntry(slug: string) { + const cm = await getConfigMap(NAMESPACE, "pgbouncer-config"); + const ini = cm["pgbouncer.ini"]; + const newLine = ` ${slug} = host=${DO_HOST} port=${DO_PORT} dbname=${slug} user=${slug} pool_mode=session pool_size=3`; + const updated = ini.replace("[pgbouncer]", `${newLine}\n [pgbouncer]`); + await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated }); +} + +async function removeDbEntry(slug: string) { + const cm = await getConfigMap(NAMESPACE, "pgbouncer-config"); + const ini = cm["pgbouncer.ini"]; + const updated = ini.split("\n").filter((l) => !l.includes(`dbname=${slug}`)).join("\n"); + await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated }); +} + +async function addUserEntry(slug: string, password: string) { + const secret = await getSecret(NAMESPACE, "pgbouncer-userlist"); + const userlist = secret["userlist.txt"]; + const updated = `${userlist}\n"${slug}" "${password}"`; + await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated }); +} + +async function removeUserEntry(slug: string) { + const secret = await getSecret(NAMESPACE, "pgbouncer-userlist"); + const userlist = secret["userlist.txt"]; + const updated = userlist.split("\n").filter((l) => !l.startsWith(`"${slug}"`)).join("\n"); + await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}