feat: Add auth flow (#146)

This adds a simple way to incorporate a centralized authentication flow.

The idea is to have the user, API and SSH (for machine authentication)
all in one place using `openauthjs` + `SST`

We also have a database now :)

> We are using InstantDB as it allows us to authenticate a use with just
the email. Plus it is super simple simple to use _of course after the
initial fumbles trying to design the db and relationships_
This commit is contained in:
Wanjohi
2025-01-04 00:02:28 +03:00
committed by GitHub
parent 33895974a7
commit fc5a755408
136 changed files with 3512 additions and 1914 deletions

5
.gitignore vendored
View File

@@ -49,4 +49,7 @@ bun.lockb
id_*
#Rust
target
target
tmp
.partykit

42
apps/docs/sst-env.d.ts vendored Normal file
View File

@@ -0,0 +1,42 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}

View File

@@ -1,7 +1,14 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"@nestri/eslint-config/qwik.js",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:qwik/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
@@ -12,5 +19,24 @@ module.exports = {
ecmaFeatures: {
jsx: true,
},
}
},
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"prefer-spread": "off",
"no-case-declarations": "off",
"no-console": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/consistent-type-imports": "warn",
"@typescript-eslint/no-unnecessary-condition": "warn",
},
};

View File

@@ -35,11 +35,10 @@
"@builder.io/qwik-city": "^1.8.0",
"@builder.io/qwik-react": "0.5.0",
"@modular-forms/qwik": "^0.27.0",
"@nestri/eslint-config": "*",
"@nestri/input": "*",
"@nestri/libmoq": "*",
"@nestri/typescript-config": "*",
"@nestri/ui": "*",
"@openauthjs/openauth": "^0.2.6",
"@types/eslint": "8.56.10",
"@types/howler": "^2.2.12",
"@types/node": "^22.5.1",

View File

@@ -1,4 +1,3 @@
// module.exports = require("@nestri/ui/postcss.config");
module.exports = {
plugins: {
tailwindcss: {},

View File

@@ -0,0 +1,9 @@
import { component$ } from "@builder.io/qwik"
export default component$(() => {
return (
<div class="font-title">
Device
</div>
)
})

View File

@@ -0,0 +1,54 @@
import { $, component$, useVisibleTask$ } from "@builder.io/qwik";
import { createClient } from "@openauthjs/openauth/client";
function getHashParams(url: URL) {
const urlString = url.toString()
const hash = urlString.substring(urlString.indexOf('#') + 1); // Extract the part after the #
console.log("url", hash)
const params = new URLSearchParams(hash);
const paramsObj = {} as any;
for (const [key, value] of params.entries()) {
paramsObj[key] = decodeURIComponent(value);
}
console.log(paramsObj)
return paramsObj;
}
function removeURLParams() {
const newURL = window.location.origin + window.location.pathname; // Just origin and path
window.location.replace(newURL);
}
export default component$(() => {
const login = $(async () => {
const client = createClient({
clientID: "www",
issuer: "https://auth.lauryn.dev.nestri.io"
})
const { url } = await client.authorize("http://localhost:5173/login", "token", { pkce: true })
window.location.href = url
})
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(async () => {
const urlObj = new URL(window.location.href);
const params = getHashParams(urlObj)
if (params.access_token && params.refresh_token) {
localStorage.setItem("access_token", params.access_token)
localStorage.setItem("refresh_token", params.refresh_token)
removeURLParams()
}
})
return (
<div class="h-screen w-screen flex justify-center items-center">
<button class="px-2 py-1 font-title text-lg bg-gray-400 rounded-lg" onClick$={login}>
Login
</button>
</div>
)
})

View File

@@ -1,119 +0,0 @@
import * as v from "valibot"
//FIXME: Make sure this works
// import { Broadcast } from "./tester";
import { cn } from "@nestri/ui/design";
import { routeLoader$ } from "@builder.io/qwik-city";
import { component$, $, useSignal } from "@builder.io/qwik";
import { MotionComponent, transition, TitleSection, Button } from "@nestri/ui/react";
import { type InitialValues, type SubmitHandler, useForm, valiForm$ } from "@modular-forms/qwik"
const Schema = v.object({
url: v.pipe(
v.string(),
v.minLength(10, "Please input a valid url"),
v.url("Please input a valid url"),
)
}, "Please fill in all the fields correctly.")
type Form = v.InferInput<typeof Schema>;
export const useFormLoader = routeLoader$<InitialValues<Form>>(async () => {
return {
url: ""
}
})
const generateRandomWord = (length: number) => {
const characters = 'abcdefghijklmnopqrstuvwxyz';
return Array.from({ length }, () => characters[Math.floor(Math.random() * characters.length)]).join('');
};
export default component$(() => {
const broadcasterOk = useSignal<boolean | undefined>();
const [state, { Form, Field }] = useForm<Form>({
loader: useFormLoader(),
validate: valiForm$(Schema)
});
const handleSubmit = $<SubmitHandler<Form>>(async (values) => {
const randomNamespace = generateRandomWord(6);
// const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace })
// setTimeout(() => {
// broadcasterOk.value = sub.isSubscribed()
// }, 1000);
});
return (
<>
<TitleSection client:load title="MoQ Checker" description="Test the connection to your Media-Over-Quic relay!" />
<MotionComponent
initial={{ opacity: 0, y: 100 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={transition}
client:load
class="flex items-center justify-center w-full"
as="div"
>
<section class="w-full flex flex-col gap-4 justify-center items-center">
<Form onSubmit$={handleSubmit} class="w-full max-w-xl flex px-3 gap-2">
<Field name="url">
{(field, props) => {
return (
<div class="w-full flex flex-col gap-2">
<div class="bg-gray-200 dark:bg-gray-800 flex rounded-lg w-full relative h-10 flex-none border focus-within:bg-gray-300/70 dark:focus-within:bg-gray-700/70 border-gray-300 dark:border-gray-700 ">
<input type="url" class={cn("w-full relative h-full bg-transparent rounded-lg p-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-400 dark:focus-within:ring-gray-600 focus-within:ring-offset-2 focus-visible:outline-none focus-within:ring-offset-gray-100 dark:focus-within:ring-offset-gray-900 placeholder:text-gray-500/70", typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-green-500", typeof broadcasterOk.value !== "undefined" && (broadcasterOk.value == false) && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-red-500")} placeholder="https://relay.domain.com" {...props} />
</div>
{field.error && (<p class='text-[0.8rem] font-medium text-danger-600 dark:text-danger-500' >{field.error}</p>)}
</div>
)
}}
</Field>
{/* <button class={cn(buttonVariants.solid({ size: "md", intent: "neutral" }), "w-max space-y-0 relative")} style={{ height: 40, marginTop: 0 }} type="submit" >
Check
</button> */}
<Button.Root
disabled={state.submitting}
isLoading={state.submitting}
// setIsLoading={setIsLoading}
client:load
//@ts-ignore
type="submit"
style={{ height: 40, marginTop: 0 }}
intent="neutral"
size="md"
class="w-max space-y-0 relative">
{/* <Button.Icon
isLoading={isLoading.value}
client:load>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
</g>
</svg>
</Button.Icon> */}
<Button.Label
loadingText="Checking..."
class="text-ellipsis whitespace-nowrap"
isLoading={state.submitting}>
Check
</Button.Label>
<div class="w-[8%]" />
</Button.Root>
</Form>
{typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true ? (
<span class="w-full text-green-500 max-w-xl flex space-y-6 px-3 gap-2">
Your relay is doing okay
</span>
) : typeof broadcasterOk.value !== "undefined" && (
<span class="w-full text-red-500 max-w-xl flex space-y-6 px-3 gap-2">
Your relay has an issue
</span>
)}
</section>
</MotionComponent>
</>
)
})

View File

@@ -1,208 +0,0 @@
// import type { Connection, SubscribeRecv } from "@nestri/libmoq/transport"
// import { asError } from "@nestri/moq/common/error"
// import { Client } from "@nestri/moq/transport/client"
// import * as Catalog from "@nestri/moq/media/catalog"
// import { type GroupWriter } from "@nestri/moq/transport/objects"
// export interface BroadcastConfig {
// namespace: string
// connection: Connection
// }
// export interface BroadcasterConfig {
// url: string
// namespace: string
// fingerprint?: string // URL to fetch TLS certificate fingerprint
// }
// export interface BroadcastConfigTrack {
// input: string
// bitrate: number
// }
// export class Broadcast {
// stream: GroupWriter | null
// subscriber: SubscribeRecv | null
// subscribed: boolean;
// readonly config: BroadcastConfig
// readonly catalog: Catalog.Root
// readonly connection: Connection
// readonly namespace: string
// #running: Promise<void>
// constructor(config: BroadcastConfig) {
// this.subscribed = false
// this.namespace = config.namespace
// this.connection = config.connection
// this.config = config
// //Arbitrary values, just to keep TypeScript happy :)
// this.catalog = {
// version: 1,
// streamingFormat: 1,
// streamingFormatVersion: "0.2",
// supportsDeltaUpdates: false,
// commonTrackFields: {
// packaging: "loc",
// renderGroup: 1,
// },
// tracks: [{
// name: "tester",
// namespace: "tester",
// selectionParams: {}
// }],
// }
// this.stream = null
// this.subscriber = null
// this.#running = this.#run()
// }
// static async init(config: BroadcasterConfig): Promise<Broadcast> {
// const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" })
// const connection = await client.connect();
// return new Broadcast({ connection, namespace: config.namespace })
// }
// async #run() {
// try {
// await this.connection.announce(this.namespace)
// this.subscribed = true
// } catch (error) {
// this.subscribed = false
// }
// for (; ;) {
// const subscriber = await this.connection.subscribed()
// if (!subscriber) {
// this.subscribed = false
// break
// }
// await subscriber.ack()
// this.subscriber = subscriber
// this.subscribed = true
// const bytes = Catalog.encode(this.catalog);
// const stream = await subscriber.group({ group: 0 });
// await stream.write({ object: 0, payload: bytes })
// this.stream = stream
// }
// }
// isSubscribed(): boolean {
// return this.subscribed;
// }
// // async #serveSubscribe(subscriber: SubscribeRecv) {
// // try {
// // // Send a SUBSCRIBE_OK
// // await subscriber.ack()
// // console.log("catalog track name:", subscriber.track)
// // const stream = await subscriber.group({ group: 0 });
// // // const bytes = this.catalog.encode("Hello World")
// // await stream.write({ object: 0, payload: bytes })
// // } catch (e) {
// // const err = asError(e)
// // await subscriber.close(1n, `failed to process publish: ${err.message}`)
// // } finally {
// // // TODO we can't close subscribers because there's no support for clean termination
// // // await subscriber.close()
// // }
// // }
// // async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) {
// // const mouse_move = {
// // input_type: "mouse_move",
// // delta_y: y,
// // delta_x: x,
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) {
// // const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button };
// // if (e.type === "mousedown") {
// // data["input_type"] = "mouse_key_down"
// // } else if (e.type === "mouseup") {
// // data["input_type"] = "mouse_key_up"
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) {
// // const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {}
// // if (e.deltaY < 0.0) {
// // data["input_type"] = "mouse_wheel_up"
// // } else {
// // data["input_type"] = "mouse_wheel_down"
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) {
// // const data = {
// // input_type: "key_up",
// // key_code: e.keyCode
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) {
// // const data = {
// // input_type: "key_down",
// // key_code: e.keyCode
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// close() {
// // TODO implement publish close
// }
// // Returns the error message when the connection is closed
// async closed(): Promise<Error> {
// try {
// await this.#running
// return new Error("closed") // clean termination
// } catch (e) {
// return asError(e)
// }
// }
// }

View File

@@ -1,12 +1,5 @@
import { component$ } from "@builder.io/qwik";
import { HomeNavBar, Card } from "@nestri/ui";
function getGreeting(): string {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return "Good Morning";
if (hour >= 12 && hour < 18) return "Good Afternoon";
return "Good Evening";
}
import { HomeNavBar } from "@nestri/ui";
export default component$(() => {
return (

View File

@@ -1,12 +1,13 @@
import {useLocation} from "@builder.io/qwik-city";
import {Keyboard, Mouse, WebRTCStream} from "@nestri/input"
import {component$, useSignal, useVisibleTask$} from "@builder.io/qwik";
import { useLocation } from "@builder.io/qwik-city";
import { Keyboard, Mouse, WebRTCStream } from "@nestri/input"
import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik";
export default component$(() => {
const id = useLocation().params.id;
const canvas = useSignal<HTMLCanvasElement>();
useVisibleTask$(({track}) => {
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(({ track }) => {
track(() => canvas.value);
if (!canvas.value) return; // Ensure canvas is available
@@ -66,9 +67,9 @@ export default component$(() => {
// @ts-ignore
if (document.pointerLockElement && !window.nestrimouse && !window.nestrikeyboard) {
// @ts-ignore
window.nestrimouse = new Mouse({canvas: canvas.value, webrtc});
window.nestrimouse = new Mouse({ canvas: canvas.value, webrtc });
// @ts-ignore
window.nestrikeyboard = new Keyboard({canvas: canvas.value, webrtc});
window.nestrikeyboard = new Keyboard({ canvas: canvas.value, webrtc });
// @ts-ignore
} else if (!document.pointerLockElement && window.nestrimouse && window.nestrikeyboard) {
// @ts-ignore
@@ -180,7 +181,7 @@ export default component$(() => {
}
}}
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
class="aspect-video h-full w-full object-contain max-h-screen"/>
class="aspect-video h-full w-full object-contain max-h-screen" />
)
})

View File

@@ -1,8 +1,8 @@
import { $, component$, noSerialize, NoSerialize, useSignal, useVisibleTask$ } from "@builder.io/qwik";
import { $, component$, noSerialize, type NoSerialize, useSignal, useVisibleTask$ } from "@builder.io/qwik";
import { TitleSection, MotionComponent, transition } from "@nestri/ui/react";
import { NavBar, Footer, Book } from "@nestri/ui"
import { cn } from "@nestri/ui/design";
import { Howl, Howler } from 'howler';
import { Howl } from 'howler';
//FIXME: Add a FAQ section
// FIXME: Takes too long for the price input radio input to become responsive
@@ -68,6 +68,7 @@ export default component$(() => {
const bookRef = useSignal<HTMLButtonElement | undefined>()
const audio = useSignal<NoSerialize<Howl> | undefined>()
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(() => {
audio.value = noSerialize(new Howl({ src: ["/audio/cash.mp3"], volume: 0.5 }))

40
apps/www/sst-env.d.ts vendored
View File

@@ -1,4 +1,42 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/// <reference path="../../sst-env.d.ts" />
/* deno-fmt-ignore-file */
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}

View File

@@ -1,22 +1,15 @@
{
"extends": "@nestri/typescript-config/base.json",
"compilerOptions": {
"allowJs": true,
"target": "ES2022",
"target": "ES2017",
"module": "ES2022",
"lib": [
"es2022",
"DOM",
"WebWorker",
"DOM.Iterable"
],
"lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "@builder.io/qwik",
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"skipLibCheck": true,
"incremental": true,
@@ -24,22 +17,9 @@
"outDir": "tmp",
"noEmit": true,
"paths": {
"@/*": [
"./src/*"
],
"content-collections": [
"./.content-collections/generated"
]
"@/*": ["./src/*"]
}
},
"files": [
".eslintrc.cjs"
],
"include": [
"src",
"./*.d.ts",
"./*.config.ts",
"./*.config.js",
"content-collections.ts"
, "../../packages/input/src/webrtc-stream.ts" ]
"files": ["./.eslintrc.cjs"],
"include": ["src", "./*.d.ts", "./*.config.ts","./*.config.cjs"]
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,22 +1,52 @@
import { isPermanentStage } from "./stage";
import { domain } from "./dns";
import { secret } from "./secrets"
//TODO: Use this instead of wrangler
// export const api = new sst.cloudflare.Worker("apiApi", {
// url: true,
// handler: "packages/api/src/index.ts",
// // live: true,
// });
sst.Linkable.wrap(random.RandomString, (resource) => ({
properties: {
value: resource.result,
},
}));
if (!isPermanentStage) {
new sst.x.DevCommand("apiDev", {
dev: {
command: "bun run dev",
directory: "packages/api",
autostart: true,
},
})
}
export const authFingerprintKey = new random.RandomString(
"AuthFingerprintKey",
{
length: 32,
},
);
// export const outputs = {
// api: api.url
// }
export const urls = new sst.Linkable("Urls", {
properties: {
api: "https://api." + domain,
auth: "https://auth." + domain,
},
});
export const kv = new sst.cloudflare.Kv("CloudflareAuthKV")
export const auth = new sst.cloudflare.Worker("Auth", {
link: [
kv,
urls,
authFingerprintKey,
secret.InstantAdminToken,
secret.InstantAppId,
secret.LoopsApiKey
],
handler: "./packages/functions/src/auth.ts",
url: true,
domain: "auth." + domain
});
export const api = new sst.cloudflare.Worker("Api", {
link: [
urls,
],
url: true,
handler: "./packages/functions/src/api/index.ts",
domain: "api." + domain
})
export const outputs = {
auth: auth.url,
api: api.url
}

10
infra/cli.ts Normal file
View File

@@ -0,0 +1,10 @@
import { auth, urls } from "./api"
// export const cmd = new sst.x.DevCommand("Cmd", {
// link: [urls, auth],
// dev: {
// autostart: true,
// command: "cd packages/cmd && go run main.go"
// }
// })

9
infra/dns.ts Normal file
View File

@@ -0,0 +1,9 @@
export const domain =
{
production: "prod.nestri.io", //temporary use until we go into the real production
dev: "dev.nestri.io",
}[$app.stage] || $app.stage + ".dev.nestri.io";
export const zone = cloudflare.getZoneOutput({
name: "nestri.io",
});

View File

@@ -1,9 +0,0 @@
// export const domain =
// {
// production: "fst.so",
// dev: "dev.fst.so",
// }[$app.stage] || $app.stage + ".dev.fst.so";
// export const zone = cloudflare.getZoneOutput({
// name: "fst.so",
// });

View File

@@ -1,22 +0,0 @@
import { resolve as pathResolve } from "node:path";
import { readFileSync as readFile } from "node:fs";
//Copy your (known) ssh public key to the remote machine
//ssh-copy-id "-p $port" user@host
const domain = "fst.so"
const ips = ["95.216.29.238"]
// Get the hosted zone
const zone = aws.route53.getZone({ name: domain });
// Create an A record
const record = new aws.route53.Record("Relay DNS Records", {
zoneId: zone.then(zone => zone.zoneId),
type: "A",
name: `relay.${domain}`,
ttl: 300,
records: ips,
});
// Export the URL
export const url = $interpolate`https://${record.name}`;

View File

@@ -1,5 +1,7 @@
export const secret = {
CloudflareAccountIdSecret: new sst.Secret("CloudflareAccountId"),
};
export const allSecrets = Object.values(secret);
InstantAdminToken: new sst.Secret("InstantAdminToken"),
InstantAppId: new sst.Secret("InstantAppId"),
LoopsApiKey: new sst.Secret("LoopsApiKey")
};
export const allSecrets = Object.values(secret);

View File

@@ -1,79 +0,0 @@
//Deploys the website to cloudflare pages under the domain nestri.io (redirects all requests to www.nestri.io to avoid duplicate content)
import { isPermanentStage } from "./stage";
export const www = new cloudflare.PagesProject("www", {
name: "nestri",
accountId: "8405b2acb6746935b975bc2cfcb5c288",
productionBranch: "main",
buildConfig: {
rootDir: "apps/www",
buildCommand: "bun run build",
destinationDir: "dist"
},
deploymentConfigs: {
production: {
compatibilityFlags: ["nodejs_compat"]
},
preview: {
compatibilityFlags: ["nodejs_compat"]
}
},
source: {
type: "github",
config: {
owner: "nestriness",
deploymentsEnabled: true,
productionBranch: "main",
repoName: "nestri",
productionDeploymentEnabled: true,
prCommentsEnabled: true,
}
}
});
//TODO: Maybe handle building Qwik ourselves? This prevents us from relying on CF too much, we are open-source anyway 🤷🏾‍♂️
//TODO: Add a local dev server for Qwik that can be linked with whatever we want
//TODO: Link the www PageRule with whatever we give to the local dev server
if (!isPermanentStage) {
new sst.x.DevCommand("www", {
dev: {
command: "bun run dev",
directory: "apps/www",
autostart: true,
},
})
}
// //This creates a resource that can be accessed by itself
// new sst.Linkable.wrap(cloudflare.PageRule, (resource) => ({
// // these properties will be available when linked
// properties: {
// arn: resource.urn
// }
// }))
// //And then you call your linkable resource like this:
// // const www = cloudflare.PageRule("www", {})
// //this creates a linkable resource that can be linked to other resources
// export const linkable2 = new sst.Linkable("ExistingResource", {
// properties: {
// arn: "arn:aws:s3:::nestri-website-artifacts-prod-nestri-io-01h70zg50qz5z"
// },
// include: [
// sst.aws.permission({
// actions: ["s3:*"],
// resources: ["arn:aws:s3:::nestri-website-artifacts-prod-nestri-io-01h70zg50qz5z"]
// }),
// sst.cloudflare.binding({
// type: "r2BucketBindings",
// properties: {
// bucketName: "nestri-website-artifacts-prod-nestri-io-01h70zg50qz5z",
// }
// })
// ]
// })
export const outputs = {
www: www.subdomain,
};

View File

@@ -4,6 +4,7 @@
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"sst": "sst dev",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"lint": "turbo lint"
},
@@ -29,6 +30,6 @@
"workerd"
],
"dependencies": {
"sst": "3.0.94"
"sst": "^3.4.32"
}
}

View File

@@ -1,33 +0,0 @@
# prod
dist/
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
.wrangler
# env
.env
.env.production
.dev.vars
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

View File

@@ -1,12 +0,0 @@
# Nexus
## Development
```
npm install
npm run dev
```
```
npm run deploy
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48.672001"
height="36.804001"
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<g id="layer1">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 590 B

View File

@@ -1,23 +0,0 @@
{
"name": "@nestri/nexus",
"version": "0.0.1",
"private": true,
"description": "Nestri's core",
"scripts": {
"dev": "wrangler dev src/index.ts",
"deploy": "wrangler deploy --minify src/index.ts"
},
"dependencies": {
"hono": "^4.5.5"
},
"devDependencies": {
"@cf-wasm/resvg": "^0.1.21",
"@cloudflare/workers-types": "^4.20240529.0",
"@jsquash/avif": "^1.3.0",
"@jsquash/jpeg": "^1.4.0",
"@jsquash/resize": "^2.0.0",
"@nestri/cache": "*",
"tinycolor2": "^1.6.0",
"wrangler": "^3.72.2"
}
}

View File

@@ -1,38 +0,0 @@
import { Hono } from 'hono'
import { Resvg } from "@cf-wasm/resvg";
import { generateGradient, createAvatarSvg } from '../utils';
import { kvCaches } from "@nestri/cache"
const cacheOptions = {
key: "nexus",
namespace: "avatar-cache"
};
const app = new Hono()
const middleware = kvCaches(cacheOptions);
app.get('/:id', middleware, async (c) => {
const id = c.req.param('id');
const [name, fileType] = id.split('.');
const size = parseInt(c.req.query("size") || "200");
const validImageTypes = ["png"] //['jpg', 'png', 'webp', 'avif'];
if (!validImageTypes.includes(fileType)) {
return c.text('Invalid image type', 400);
}
const gradient = generateGradient(name || Math.random() + "");
// console.log(`gradient: ${JSON.stringify(gradient)}`)
const svg = createAvatarSvg(size, gradient, fileType);
const resvg = await Resvg.create(svg.toString());
const pngData = resvg.render()
const pngBuffer = pngData.asPng()
return c.newResponse(pngBuffer, 200, {
"Content-Type": `image/${fileType}`,
'Cache-Control': 'public, max-age=31536000, immutable'
})
})
export default app

View File

@@ -1,72 +0,0 @@
import { Hono } from 'hono'
import { kvCaches } from "@nestri/cache"
import resize, { initResize } from '@jsquash/resize';
import decodeJpeg, { init as initDecodeJpeg } from '@jsquash/jpeg/decode';
import encodeAvif, { init as initEncodeAvif } from '@jsquash/avif/encode.js';
import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm";
import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm";
import AVIF_ENC_WASM from "@jsquash/avif/codec/enc/avif_enc.wasm";
const cacheOptions = {
key: "nexus",
namespace: "cover-cache"
};
const middleware = kvCaches(cacheOptions);
const decodeImage = async (buffer: ArrayBuffer) => {
await initDecodeJpeg(JPEG_DEC_WASM);
return decodeJpeg(buffer);
}
const resizeImage = async (image: { width: number; height: number }, width: number, height: number) => {
await initResize(RESIZE_WASM);
// Resize image with respect to aspect ratio
const aspectRatio = image.width / image.height;
const newWidth = width;
const newHeight = width / aspectRatio;
return resize(image, { width: newWidth, height: newHeight, fitMethod: "stretch" });
}
const encodeImage = async (image: { width: number; height: number }, format: string) => {
if (format === 'avif') {
await initEncodeAvif(AVIF_ENC_WASM);
return encodeAvif(image);
}
throw new Error(`Unsupported image format: ${format}`);
}
const app = new Hono()
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404))
app.get('/:id', middleware, async (c) => {
const [gameId, imageType] = c.req.param("id").split('.');
const width = parseInt(c.req.query("width") || "460");
//We don't even use this, but let us keep it for future use
const height = parseInt(c.req.query("height") || "215");
if (!gameId || !imageType) {
return c.text("Invalid image parameters", 400)
}
//Support Avif only because of it's small size
const validImageTypes = ["avif"] //['jpg', 'png', 'webp', 'avif'];
if (!validImageTypes.includes(imageType)) {
return c.text('Invalid image type', 400);
}
const imageUrl = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${gameId}/header.jpg`;
const image = await fetch(imageUrl);
if (!image.ok) {
return c.text('Image not found', 404);
}
const imageBuffer = await image.arrayBuffer();
const imageData = await decodeImage(imageBuffer);
const resizedImage = await resizeImage(imageData, width, height);
const resizedImageBuffer = await encodeImage(resizedImage, imageType);
return c.newResponse(resizedImageBuffer, 200, {
"Content-Type": `image/${imageType}`,
'Cache-Control': 'public, max-age=31536000, immutable'
})
})
export default app

View File

@@ -1,72 +0,0 @@
import { Hono } from 'hono'
import { kvCaches } from "@nestri/cache"
import resize, { initResize } from '@jsquash/resize';
import decodeJpeg, { init as initDecodeJpeg } from '@jsquash/jpeg/decode';
import encodeAvif, { init as initEncodeAvif } from '@jsquash/avif/encode.js';
import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm";
import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm";
import AVIF_ENC_WASM from "@jsquash/avif/codec/enc/avif_enc.wasm";
const cacheOptions = {
key: "nexus",
namespace: "cover-cache"
};
const middleware = kvCaches(cacheOptions);
const decodeImage = async (buffer: ArrayBuffer) => {
await initDecodeJpeg(JPEG_DEC_WASM);
return decodeJpeg(buffer);
}
const resizeImage = async (image: { width: number; height: number }, width: number, height: number) => {
await initResize(RESIZE_WASM);
// Resize image with respect to aspect ratio
const aspectRatio = image.width / image.height;
const newWidth = width;
const newHeight = width / aspectRatio;
return resize(image, { width: newWidth, height: newHeight, fitMethod: "stretch" });
}
const encodeImage = async (image: { width: number; height: number }, format: string) => {
if (format === 'avif') {
await initEncodeAvif(AVIF_ENC_WASM);
return encodeAvif(image);
}
throw new Error(`Unsupported image format: ${format}`);
}
const app = new Hono()
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404))
app.get('/:id', middleware, async (c) => {
const [gameId, imageType] = c.req.param("id").split('.');
const width = parseInt(c.req.query("width") || "600");
//We don't even use this, but let us keep it for future use
const height = parseInt(c.req.query("height") || "900");
if (!gameId || !imageType) {
return c.text("Invalid image parameters", 400)
}
//Support Avif only because of it's small size
const validImageTypes = ["avif"] //['jpg', 'png', 'webp', 'avif'];
if (!validImageTypes.includes(imageType)) {
return c.text('Invalid image type', 400);
}
const imageUrl = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${gameId}/library_600x900_2x.jpg`;
const image = await fetch(imageUrl);
if (!image.ok) {
return c.text('Image not found', 404);
}
const imageBuffer = await image.arrayBuffer();
const imageData = await decodeImage(imageBuffer);
const resizedImage = await resizeImage(imageData, width, height);
const resizedImageBuffer = await encodeImage(resizedImage, imageType);
return c.newResponse(resizedImageBuffer, 200, {
"Content-Type": `image/${imageType}`,
'Cache-Control': 'public, max-age=31536000, immutable'
})
})
export default app

View File

@@ -1,39 +0,0 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors';
import Cover from './cover'
import Avatar from './avatar'
import Banner from './banner'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello There! 👋🏾')
})
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404))
app.use(
'/*',
cors({
origin: (origin, c) => {
const allowedOriginPatterns = [
/^https:\/\/.*\.nestri\.pages\.dev$/,
/^https:\/\/.*\.nestri\.io$/,
/^http:\/\/localhost:\d+$/ // For local development
];
return allowedOriginPatterns.some(pattern => pattern.test(origin))
? origin
: 'https://nexus.nestri.io'
},
})
)
app.route('/cover', Cover)
app.route('/avatar', Avatar)
app.route("/banner", Banner)
export default app

View File

@@ -1,18 +0,0 @@
import { Hono } from 'hono'
import Image from "./image"
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello There! 👋🏾')
})
app.get('/favicon.ico', (c) => {
return c.newResponse(null, 302, {
Location: 'https://nestri.pages.dev/favicon.svg'
})
})
app.route("/image", Image)
export default app

View File

@@ -1,4 +0,0 @@
declare module '*wasm' {
const content: any;
export default content;
}

View File

@@ -1,33 +0,0 @@
export const createAvatarSvg = (size: number, gradient: { fromColor: string, toColor: string }, fileType?: string, text?: string) => (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={gradient.fromColor} />
<stop offset="100%" stopColor={gradient.toColor} />
</linearGradient>
</defs>
<rect fill="url(#gradient)" x="0" y="0" width={size} height={size} />
{fileType === "svg" && text && (
<text
x="50%"
y="50%"
alignmentBaseline="central"
dominantBaseline="central"
textAnchor="middle"
fill="#fff"
fontFamily="sans-serif"
fontSize={(size * 0.9) / text.length}
>
{text}
</text>
)}
</g>
</svg>
);

View File

@@ -1,24 +0,0 @@
import color from "tinycolor2";
function fnv1a(str: string) {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
export function generateGradient(username: string) {
const hash = fnv1a(username);
const hue1 = hash % 360;
const hue2 = (hash >> 16) % 360;
const c1 = color({ h: hue1, s: 0.8, l: 0.6 });
const c2 = color({ h: hue2, s: 0.8, l: 0.5 });
return {
fromColor: c1.toHexString(),
toColor: c2.toHexString(),
};
}

View File

@@ -1,2 +0,0 @@
export * from './gradient'
export * from './create-avatar'

View File

@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": [
"@cloudflare/workers-types/2023-07-01"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
}

View File

@@ -1,26 +0,0 @@
name = "nexus"
compatibility_date = "2024-08-14"
send_metrics = false
[[kv_namespaces]]
binding = "nexus"
id = "e21527f9f1ed4adbaca1fa23d7f147c9"
# [vars]
# MY_VAR = "my-variable"
# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# [[r2_buckets]]
# binding = "MY_BUCKET"
# bucket_name = "my-bucket"
# [[d1_databases]]
# binding = "DB"
# database_name = "my-database"
# database_id = ""
# [ai]
# binding = "AI"

View File

@@ -1,47 +0,0 @@
const buildCacheKey = (namespace: string) => (key: string) => {
return `${namespace}:${key}`;
};
export interface KVResponseCache {
match(key: string): Promise<Response | null>;
put(key: string, res: Response, options?: KVNamespacePutOptions): Promise<void>;
delete(key: string): Promise<void>;
}
export const kvResponseCache =
(kv: KVNamespace) =>
(cacheName: string): KVResponseCache => {
const cacheKey = buildCacheKey(cacheName);
return {
async match(key: string) {
const [headers, status, body] = await Promise.all([
kv.get(cacheKey(`${key}:headers`)),
kv.get(cacheKey(`${key}:status`)),
kv.get(cacheKey(`${key}:body`), "stream"),
]);
if (headers === null || body === null || status === null) return null;
return new Response(body, { headers: JSON.parse(headers), status: parseInt(status, 10) });
},
async put(key: string, res: Response, options?: KVNamespacePutOptions) {
const headers = Array.from(res.headers.entries()).reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
const body = res.body;
if (body === null) return;
await Promise.all([
kv.put(cacheKey(`${key}:headers`), JSON.stringify(headers), options),
kv.put(cacheKey(`${key}:status`), `${res.status}`, options),
kv.put(cacheKey(`${key}:body`), body, options),
]);
},
async delete(key: string) {
await Promise.all([
kv.delete(cacheKey(`${key}:headers`)),
kv.delete(cacheKey(`${key}:status`)),
kv.delete(cacheKey(`${key}:body`)),
]);
},
};
};

View File

@@ -1,8 +0,0 @@
//copied from https://github.com/napolab/kv-response-cache with some minor changes
import { kvResponseCache } from "./caches";
import { kvCaches, defaultGetCacheKey } from "./middleware";
import type { KVResponseCache } from "./caches";
export type { KVResponseCache };
export { kvResponseCache, kvCaches, defaultGetCacheKey as getCacheKey };

View File

@@ -1,47 +0,0 @@
import { kvResponseCache } from "./caches";
import type { Filter } from "./types";
import type { Context, Env, MiddlewareHandler } from "hono";
type Namespace<E extends Env> = string | ((c: Context<E>) => string);
interface GetCacheKey<E extends Env> {
(c: Context<E>): string;
}
type KVCacheOption<E extends Env & { Bindings: Record<string, unknown> }> = {
key: keyof E["Bindings"];
namespace: Namespace<E>;
getCacheKey?: GetCacheKey<E>;
options?: KVNamespacePutOptions;
};
export const defaultGetCacheKey = <E extends Env>(c: Context<E>) => c.req.url;
export const kvCaches =
<E extends Env & { Bindings: Record<string, unknown> }>({
key: bindingKey,
namespace,
options,
getCacheKey = defaultGetCacheKey,
}: KVCacheOption<E>): MiddlewareHandler<E> =>
async (c, next) => {
const kv: KVNamespace = c.env?.[bindingKey] as KVNamespace;
const kvNamespace = typeof namespace === "function" ? namespace(c) : namespace;
const kvCaches = kvResponseCache(kv);
const cache = kvCaches(kvNamespace);
const key = getCacheKey(c);
const response = await cache.match(key);
if (response) {
response.headers.set("X-KV-CACHE", "hit");
return response;
}
await next();
if (c.res.status >= 200 && c.res.status < 300) {
c.executionCtx.waitUntil(cache.put(key, c.res.clone(), options));
}
};

View File

@@ -1,13 +0,0 @@
{
"name": "@nestri/cache",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"exports":{
".":"./index.ts"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240529.0",
"wrangler": "^3.57.2"
}
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": [
"@cloudflare/workers-types/2023-07-01"
]
},
}

View File

@@ -1,3 +0,0 @@
export type Filter<T extends Record<string, unknown>, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};

View File

@@ -1,3 +0,0 @@
.terraform
relay_*
terraform.tfstate

View File

@@ -1,61 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/local" {
version = "2.5.2"
hashes = [
"h1:JlMZD6nYqJ8sSrFfEAH0Vk/SL8WLZRmFaMUF9PJK5wM=",
"zh:136299545178ce281c56f36965bf91c35407c11897f7082b3b983d86cb79b511",
"zh:3b4486858aa9cb8163378722b642c57c529b6c64bfbfc9461d940a84cd66ebea",
"zh:4855ee628ead847741aa4f4fc9bed50cfdbf197f2912775dd9fe7bc43fa077c0",
"zh:4b8cd2583d1edcac4011caafe8afb7a95e8110a607a1d5fb87d921178074a69b",
"zh:52084ddaff8c8cd3f9e7bcb7ce4dc1eab00602912c96da43c29b4762dc376038",
"zh:71562d330d3f92d79b2952ffdda0dad167e952e46200c767dd30c6af8d7c0ed3",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:805f81ade06ff68fa8b908d31892eaed5c180ae031c77ad35f82cb7a74b97cf4",
"zh:8b6b3ebeaaa8e38dd04e56996abe80db9be6f4c1df75ac3cccc77642899bd464",
"zh:ad07750576b99248037b897de71113cc19b1a8d0bc235eb99173cc83d0de3b1b",
"zh:b9f1c3bfadb74068f5c205292badb0661e17ac05eb23bfe8bd809691e4583d0e",
"zh:cc4cbcd67414fefb111c1bf7ab0bc4beb8c0b553d01719ad17de9a047adff4d1",
]
}
provider "registry.terraform.io/hashicorp/tls" {
version = "4.0.6"
hashes = [
"h1:dYSb3V94K5dDMtrBRLPzBpkMTPn+3cXZ/kIJdtFL+2M=",
"zh:10de0d8af02f2e578101688fd334da3849f56ea91b0d9bd5b1f7a243417fdda8",
"zh:37fc01f8b2bc9d5b055dc3e78bfd1beb7c42cfb776a4c81106e19c8911366297",
"zh:4578ca03d1dd0b7f572d96bd03f744be24c726bfd282173d54b100fd221608bb",
"zh:6c475491d1250050765a91a493ef330adc24689e8837a0f07da5a0e1269e11c1",
"zh:81bde94d53cdababa5b376bbc6947668be4c45ab655de7aa2e8e4736dfd52509",
"zh:abdce260840b7b050c4e401d4f75c7a199fafe58a8b213947a258f75ac18b3e8",
"zh:b754cebfc5184873840f16a642a7c9ef78c34dc246a8ae29e056c79939963c7a",
"zh:c928b66086078f9917aef0eec15982f2e337914c5c4dbc31dd4741403db7eb18",
"zh:cded27bee5f24de6f2ee0cfd1df46a7f88e84aaffc2ecbf3ff7094160f193d50",
"zh:d65eb3867e8f69aaf1b8bb53bd637c99c6b649ba3db16ded50fa9a01076d1a27",
"zh:ecb0c8b528c7a619fa71852bb3fb5c151d47576c5aab2bf3af4db52588722eeb",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
provider "registry.terraform.io/vancluever/acme" {
version = "2.26.0"
constraints = "~> 2.0"
hashes = [
"h1:4Lk5cb2Fg1q1JEQf1jkrShjPC3ayukp4eFcdL4e+y0w=",
"zh:11f554916ee99d8930de6d7bb5a014ec636b53ef9ba35eea84b0d2522c78230f",
"zh:231c31271c25477c95e0a4972857b6d5e9d7c3a300cbc4b0948566d87bc46e04",
"zh:2ae165ca7a994a4c77801a82ebd9f2f6de33a4c8882381bea575b3385cc251d8",
"zh:2cf01e4694d81b24972f5dab8e5f374aa59100082ff6e2435615d9c0f24cc00e",
"zh:3de6f6f9d052dfaa5d5f366d7ca26bdebb42fc74b6e19325e67420c37ff630d3",
"zh:3fd2b4b680b970394e4d0d49c2a8e5365297e79cea418ce87197cc8bb456d8c7",
"zh:46ea249cc01dce23ff6c8f02106e693be3b046059834b60b670c45a8f4093980",
"zh:57cb181c73b6e7397744d885c788d8815ad6a43f07769e98c6327bbc37272896",
"zh:761f2adf3e63559bd279763eb91247cdebf31401d79853755453274f143cbb36",
"zh:c4a9905bf81d38201c080cb91ea85002194c47ca26619644628184a56c394b7d",
"zh:d6e3a757c357239edefb640807778fb69805b9ae5df84a811a2d505c51089367",
"zh:d713856e4a459e1091cbb19ffb830d25cd88953d3e54acd46db0729c77a531d8",
"zh:f7cb8dec263d0ee223737dad3b6fa8071258f41cfa9e0b8cf7f337f9f501fc3b",
]
}

View File

@@ -1,24 +0,0 @@
## Usage
1. Update the terraform.tfvars file with your domain and email.
2. Run `terraform init` to initialize the Terraform working directory.
3. Run `terraform plan` to see the planned changes.
4. Run `terraform apply` to create the resources and obtain the certificate.
Outputs
The configuration provides two sensitive outputs:
```bash
certificate_pem: The full certificate chain
private_key_pem: The private key for the certificate
```
These can be then be used in your `moq-relay` as it requires SSL/TLS certificates.
## Note
The generated certificate and key files are saved locally and ignored by git:
```git
.terraform
relay_*
```

View File

@@ -1,7 +0,0 @@
variable "email" {
description = "Your email address, used for LetsEncrypt"
}
variable "domain" {
description = "domain name"
}

View File

@@ -1,65 +0,0 @@
terraform {
required_providers {
acme = {
source = "vancluever/acme"
version = "~> 2.0"
}
}
}
provider "acme" {
server_url = "https://acme-v02.api.letsencrypt.org/directory"
}
resource "acme_registration" "reg" {
email_address = "wanjohiryan33@gmail.com"
}
resource "tls_private_key" "relay" {
algorithm = "ECDSA"
ecdsa_curve = "P256"
}
resource "acme_registration" "relay" {
account_key_pem = tls_private_key.relay.private_key_pem
email_address = var.email
}
resource "acme_certificate" "relay" {
account_key_pem = acme_registration.relay.account_key_pem
common_name = "relay.${var.domain}"
subject_alternative_names = ["*.relay.${var.domain}"]
key_type = tls_private_key.relay.ecdsa_curve
recursive_nameservers = ["8.8.8.8:53"]
dns_challenge {
provider = "route53"
}
}
# New resources to save certificate and private key
resource "local_file" "cert_file" {
content = "${acme_certificate.relay.certificate_pem}${acme_certificate.relay.issuer_pem}"
filename = "${path.module}/relay_cert.crt"
file_permission = "0644"
directory_permission = "0755"
}
resource "local_file" "key_file" {
content = acme_certificate.relay.private_key_pem
filename = "${path.module}/relay_key.key"
file_permission = "0600"
directory_permission = "0755"
}
# Outputs for certificate and private key
output "certificate_pem" {
value = "${acme_certificate.relay.certificate_pem}${acme_certificate.relay.issuer_pem}"
sensitive = true
}
output "private_key_pem" {
value = acme_certificate.relay.private_key_pem
sensitive = true
}

View File

@@ -1,2 +0,0 @@
domain = "fst.so"
email = "wanjohiryan33@gmail.com"

50
packages/cli/cmd/root.go Normal file
View File

@@ -0,0 +1,50 @@
package cmd
import (
"runtime/debug"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "nestri",
Short: "A CLI tool to run and manage your self-hosted cloud gaming service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() error {
err := rootCmd.Execute()
return err
}
var (
// Version stores the build version of VHS at the time of package through
// -ldflags.
//
// go build -ldflags "-s -w -X=main.Version=$(VERSION)"
Version string
// CommitSHA stores the git commit SHA at the time of package through -ldflags.
CommitSHA string
)
func init() {
rootCmd.AddCommand(runCmd)
if len(CommitSHA) >= 7 { //nolint:gomnd
vt := rootCmd.VersionTemplate()
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
}
if Version == "" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
Version = info.Main.Version
} else {
Version = "unknown (built from source)"
}
}
rootCmd.Version = Version
}

26
packages/cli/cmd/run.go Normal file
View File

@@ -0,0 +1,26 @@
package cmd
import (
"nestrilabs/cli/internal/auth"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
)
var runCmd = &cobra.Command{
Use: "run",
Short: "Run a new Nestri node",
Long: "Create and run a new Nestri node from this machine",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
credentials, err := auth.FetchUserCredentials()
if err != nil {
return err
}
log.Info("Credentials", "access_token", credentials.AccessToken)
log.Info("Credentials", "refresh_token", credentials.RefreshToken)
return nil
},
}

54
packages/cli/go.mod Normal file
View File

@@ -0,0 +1,54 @@
module nestrilabs/cli
go 1.23.3
require (
github.com/charmbracelet/log v0.4.0
github.com/docker/docker v27.4.1+incompatible
github.com/gorilla/websocket v1.5.3
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3
github.com/spf13/cobra v1.8.1
)
require (
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.13.0 // indirect
github.com/charmbracelet/x/ansi v0.2.3 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/time v0.8.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

View File

@@ -2,22 +2,33 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -29,17 +40,29 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3 h1:IqtLHbOF3y/SD3riYYKauQKj9dpqU7uuEExqL5zQ390=
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3/go.mod h1:b4AuAQSxfqtAzu4ie0Q+NOVNF9YUZTyP4XnxK0ZN05U=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -49,42 +72,63 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -93,12 +137,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -109,14 +154,15 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=

View File

@@ -0,0 +1,26 @@
package api
import (
"context"
"fmt"
"nestrilabs/cli/internal/resource"
"github.com/nestrilabs/nestri-go-sdk"
"github.com/nestrilabs/nestri-go-sdk/option"
)
func RegisterMachine(token string) {
client := nestri.NewClient(
option.WithBearerToken(token),
option.WithBaseURL(resource.Resource.Api.Url),
)
machine, err := client.Machines.New(
context.TODO(),
nestri.MachineNewParams{})
if err != nil {
panic(err.Error())
}
fmt.Printf("%+v\n", machine.Data)
}

View File

@@ -0,0 +1,44 @@
package auth
import (
"encoding/json"
"fmt"
"io"
"nestrilabs/cli/internal/machine"
"nestrilabs/cli/internal/resource"
"net/http"
"net/url"
)
type UserCredentials struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
func FetchUserCredentials() (*UserCredentials, error) {
m := machine.NewMachine()
fingerprint := m.GetMachineID()
data := url.Values{}
data.Set("grant_type", "client_credentials")
data.Set("client_id", "device")
data.Set("client_secret", resource.Resource.AuthFingerprintKey.Value)
data.Set("hostname", m.Hostname)
data.Set("fingerprint", fingerprint)
data.Set("provider", "device")
resp, err := http.PostForm(resource.Resource.Auth.Url+"/token", data)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
return nil, fmt.Errorf("failed to auth: " + string(body))
}
credentials := UserCredentials{}
err = json.NewDecoder(resp.Body).Decode(&credentials)
if err != nil {
return nil, err
}
return &credentials, nil
}

View File

@@ -0,0 +1,202 @@
package machine
import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"github.com/charmbracelet/log"
)
type Machine struct {
OperatingSystem string
Arch string
Kernel string
Virtualization string
Hostname string
}
func NewMachine() *Machine {
var OS string
var architecture string
var kernel string
var virtualisation string
var hostname string
output, _ := exec.Command("hostnamectl", "status").Output()
os := regexp.MustCompile(`Operating System:\s+(.*)`)
matchingOS := os.FindStringSubmatch(string(output))
if len(matchingOS) > 1 {
OS = matchingOS[1]
}
arch := regexp.MustCompile(`Architecture:\s+(\w+)`)
matchingArch := arch.FindStringSubmatch(string(output))
if len(matchingArch) > 1 {
architecture = matchingArch[1]
}
kern := regexp.MustCompile(`Kernel:\s+(.*)`)
matchingKernel := kern.FindStringSubmatch(string(output))
if len(matchingKernel) > 1 {
kernel = matchingKernel[1]
}
virt := regexp.MustCompile(`Virtualization:\s+(\w+)`)
matchingVirt := virt.FindStringSubmatch(string(output))
if len(matchingVirt) > 1 {
virtualisation = matchingVirt[1]
}
host := regexp.MustCompile(`Static hostname:\s+(.*)`)
matchingHost := host.FindStringSubmatch(string(output))
if len(matchingHost) > 1 {
hostname = matchingHost[1]
}
return &Machine{
OperatingSystem: OS,
Arch: architecture,
Kernel: kernel,
Virtualization: virtualisation,
Hostname: hostname,
}
}
func (m *Machine) GetOS() string {
if m.OperatingSystem != "" {
return m.OperatingSystem
}
return "unknown"
}
func (m *Machine) GetArchitecture() string {
if m.Arch != "" {
return m.Arch
}
return "unknown"
}
func (m *Machine) GetKernel() string {
if m.Kernel != "" {
return m.Kernel
}
return "unknown"
}
func (m *Machine) GetVirtualization() string {
if m.Virtualization != "" {
return m.Virtualization
}
return "none"
}
func (m *Machine) GetHostname() string {
if m.Hostname != "" {
return m.Hostname
}
return "unknown"
}
func (m *Machine) GetMachineID() string {
id, err := os.ReadFile("/etc/machine-id")
if err != nil {
log.Error("Error getting your machine's ID", "err", err)
os.Exit(1)
}
return strings.TrimSpace(string(id))
}
func (m *Machine) GPUInfo() (string, string, error) {
// The command for GPU information varies depending on the system and drivers.
// lshw is a good general-purpose tool, but might need adjustments for specific hardware.
output, err := exec.Command("lshw", "-C", "display").Output()
if err != nil {
return "", "", fmt.Errorf("failed to get GPU information: %w", err)
}
gpuType := ""
gpuSize := ""
// Regular expressions for extracting product and size information. These might need to be
// adapted based on the output of lshw on your specific system.
typeRegex := regexp.MustCompile(`product:\s+(.*)`)
sizeRegex := regexp.MustCompile(`size:\s+(\d+MiB)`) // Example: extracts size in MiB
typeMatch := typeRegex.FindStringSubmatch(string(output))
if len(typeMatch) > 1 {
gpuType = typeMatch[1]
}
sizeMatch := sizeRegex.FindStringSubmatch(string(output))
if len(sizeMatch) > 1 {
gpuSize = sizeMatch[1]
}
if gpuType == "" && gpuSize == "" {
return "", "", fmt.Errorf("could not parse GPU information using lshw")
}
return gpuType, gpuSize, nil
}
func (m *Machine) GetCPUInfo() (string, string, error) {
output, err := exec.Command("lscpu").Output()
if err != nil {
return "", "", fmt.Errorf("failed to get CPU information: %w", err)
}
cpuType := ""
cpuSize := "" // This will store the number of cores
typeRegex := regexp.MustCompile(`Model name:\s+(.*)`)
coresRegex := regexp.MustCompile(`CPU\(s\):\s+(\d+)`)
typeMatch := typeRegex.FindStringSubmatch(string(output))
if len(typeMatch) > 1 {
cpuType = typeMatch[1]
}
coresMatch := coresRegex.FindStringSubmatch(string(output))
if len(coresMatch) > 1 {
cpuSize = coresMatch[1]
}
if cpuType == "" && cpuSize == "" {
return "", "", fmt.Errorf("could not parse CPU information using lscpu")
}
return cpuType, cpuSize, nil
}
func (m *Machine) GetRAMSize() (string, error) {
output, err := exec.Command("free", "-h", "--si").Output() // Using -h for human-readable and --si for base-10 units
if err != nil {
return "", fmt.Errorf("failed to get RAM information: %w", err)
}
ramSize := ""
ramRegex := regexp.MustCompile(`Mem:\s+(\S+)`) // Matches the total memory size
ramMatch := ramRegex.FindStringSubmatch(string(output))
if len(ramMatch) > 1 {
ramSize = ramMatch[1]
} else {
return "", fmt.Errorf("could not parse RAM information from free command")
}
return ramSize, nil
}
// func cleanString(s string) string {
// s = strings.ToLower(s)
// reg := regexp.MustCompile("[^a-z0-9]+") // Matches one or more non-alphanumeric characters
// return reg.ReplaceAllString(s, "")
// }

View File

@@ -0,0 +1,112 @@
package party
import (
"fmt"
"nestrilabs/cli/internal/machine"
"net/url"
"time"
"github.com/charmbracelet/log"
"github.com/gorilla/websocket"
)
const (
// Initial retry delay
initialRetryDelay = 1 * time.Second
// Maximum retry delay
maxRetryDelay = 30 * time.Second
// Factor to increase delay by after each attempt
backoffFactor = 2
)
type Party struct {
// Channel to signal shutdown
done chan struct{}
fingerprint string
hostname string
}
func NewParty() *Party {
m := machine.NewMachine()
fingerpint := m.GetMachineID()
return &Party{
done: make(chan struct{}),
fingerprint: fingerpint,
hostname: m.Hostname,
}
}
// Shutdown gracefully closes the connection
func (p *Party) Shutdown() {
close(p.done)
}
func (p *Party) Connect() {
baseURL := fmt.Sprintf("ws://localhost:1999/parties/main/%s", p.fingerprint)
params := url.Values{}
params.Add("_pk", p.hostname)
wsURL := baseURL + "?" + params.Encode()
retryDelay := initialRetryDelay
for {
select {
case <-p.done:
log.Info("Shutting down connection")
return
default:
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
log.Error("Failed to connect to party server", "err", err)
time.Sleep(retryDelay)
// Increase retry delay exponentially, but cap it
retryDelay = time.Duration(float64(retryDelay) * backoffFactor)
if retryDelay > maxRetryDelay {
retryDelay = maxRetryDelay
}
continue
}
// Reset retry delay on successful connection
retryDelay = initialRetryDelay
// Handle connection in a separate goroutine
connectionClosed := make(chan struct{})
go func() {
defer close(connectionClosed)
defer conn.Close()
// Send initial message
if err := conn.WriteMessage(websocket.TextMessage, []byte("hello there")); err != nil {
log.Error("Failed to send initial message", "err", err)
return
}
// Read messages loop
for {
select {
case <-p.done:
return
default:
_, message, err := conn.ReadMessage()
if err != nil {
log.Error("Error reading message", "err", err)
return
}
log.Info("Received message from party server", "message", string(message))
}
}
}()
// Wait for either connection to close or shutdown signal
select {
case <-connectionClosed:
log.Warn("Connection closed, attempting to reconnect...")
time.Sleep(retryDelay)
case <-p.done:
log.Info("Shutting down connection")
return
}
}
}
}

View File

@@ -0,0 +1,125 @@
package party
import (
"encoding/json"
"fmt"
"nestrilabs/cli/internal/machine"
"net/url"
"time"
"github.com/charmbracelet/log"
"github.com/gorilla/websocket"
)
// RetryConfig holds configuration for retry behavior
type RetryConfig struct {
InitialDelay time.Duration
MaxDelay time.Duration
BackoffFactor float64
MaxAttempts int // use 0 for infinite retries
}
// DefaultRetryConfig provides sensible default values
var DefaultRetryConfig = RetryConfig{
InitialDelay: time.Second,
MaxDelay: 30 * time.Second,
BackoffFactor: 2.0,
MaxAttempts: 0, // infinite retries
}
// RetryFunc is a function that will be retried
type RetryFunc[T any] func() (T, error)
// Retry executes the given function with retries based on the config
func Retry[T any](config RetryConfig, operation RetryFunc[T]) (T, error) {
var result T
currentDelay := config.InitialDelay
attempts := 0
for {
if config.MaxAttempts > 0 && attempts >= config.MaxAttempts {
return result, fmt.Errorf("max retry attempts (%d) exceeded", config.MaxAttempts)
}
result, err := operation()
if err == nil {
return result, nil
}
log.Warn("Operation failed, retrying...",
"attempt", attempts+1,
"delay", currentDelay,
"error", err)
time.Sleep(currentDelay)
// Increase delay for next attempt
currentDelay = time.Duration(float64(currentDelay) * config.BackoffFactor)
if currentDelay > config.MaxDelay {
currentDelay = config.MaxDelay
}
attempts++
}
}
// MessageHandler processes a message and returns true if it's the expected type
type MessageHandler[T any] func(msg T) bool
type TypeListener[T any] struct {
retryConfig RetryConfig
handler MessageHandler[T]
fingerprint string
hostname string
}
func NewTypeListener[T any](handler MessageHandler[T]) *TypeListener[T] {
m := machine.NewMachine()
fingerprint := m.GetMachineID()
return &TypeListener[T]{
retryConfig: DefaultRetryConfig,
handler: handler,
fingerprint: fingerprint,
hostname: m.Hostname,
}
}
// SetRetryConfig allows customizing the retry behavior
func (t *TypeListener[T]) SetRetryConfig(config RetryConfig) {
t.retryConfig = config
}
func (t *TypeListener[T]) ConnectUntilMessage() (T, error) {
baseURL := fmt.Sprintf("ws://localhost:1999/parties/main/%s", t.fingerprint)
params := url.Values{}
params.Add("_pk", t.hostname)
wsURL := baseURL + "?" + params.Encode()
return Retry(t.retryConfig, func() (T, error) {
var result T
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return result, fmt.Errorf("connection failed: %w", err)
}
defer conn.Close()
// Read messages until we get the one we want
for {
_, message, err := conn.ReadMessage()
if err != nil {
return result, fmt.Errorf("read error: %w", err)
}
if err := json.Unmarshal(message, &result); err != nil {
// log.Error("Failed to unmarshal message", "err", err)
continue
}
if t.handler(result) {
return result, nil
}
}
})
}

View File

@@ -0,0 +1,38 @@
package resource
import (
"encoding/json"
"fmt"
"os"
"reflect"
)
type resource struct {
Api struct {
Url string `json:"url"`
}
Auth struct {
Url string `json:"url"`
}
AuthFingerprintKey struct {
Value string `json:"value"`
}
}
var Resource resource
func init() {
val := reflect.ValueOf(&Resource).Elem()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
typeField := val.Type().Field(i)
envVarName := fmt.Sprintf("SST_RESOURCE_%s", typeField.Name)
envValue, exists := os.LookupEnv(envVarName)
if !exists {
panic(fmt.Sprintf("Environment variable %s is required", envVarName))
}
if err := json.Unmarshal([]byte(envValue), field.Addr().Interface()); err != nil {
panic(err)
}
}
}

View File

@@ -0,0 +1,286 @@
package session
import (
"context"
"fmt"
"io"
"os"
"strings"
"sync"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
)
// GPUType represents the type of GPU available
type GPUType int
const (
GPUNone GPUType = iota
GPUNvidia
GPUIntelAMD
)
// Session represents a Docker container session
type Session struct {
client *client.Client
containerID string
imageName string
config *SessionConfig
mu sync.RWMutex
isRunning bool
}
// SessionConfig holds the configuration for the session
type SessionConfig struct {
Room string
Resolution string
Framerate string
RelayURL string
Params string
GamePath string
}
// NewSession creates a new Docker session
func NewSession(config *SessionConfig) (*Session, error) {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %v", err)
}
return &Session{
client: cli,
imageName: "archlinux", //"ghcr.io/datcaptainhorse/nestri-cachyos:latest-noavx2",
config: config,
}, nil
}
// Start initiates the Docker container session
func (s *Session) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.isRunning {
return fmt.Errorf("session is already running")
}
// Detect GPU type
gpuType := detectGPU()
if gpuType == GPUNone {
return fmt.Errorf("no supported GPU detected")
}
// Get GPU-specific configurations
deviceRequests, err := getGPUDeviceRequests(gpuType)
if err != nil {
return err
}
devices := getGPUDevices(gpuType)
// Check if image exists locally
_, _, err = s.client.ImageInspectWithRaw(ctx, s.imageName)
if err != nil {
// Pull the image if it doesn't exist
reader, err := s.client.ImagePull(ctx, s.imageName, image.PullOptions{})
if err != nil {
return fmt.Errorf("failed to pull image: %v", err)
}
defer reader.Close()
// Copy pull output to stdout
io.Copy(os.Stdout, reader)
}
// Create container
resp, err := s.client.ContainerCreate(ctx, &container.Config{
Image: s.imageName,
Env: []string{
fmt.Sprintf("NESTRI_ROOM=%s", s.config.Room),
fmt.Sprintf("RESOLUTION=%s", s.config.Resolution),
fmt.Sprintf("NESTRI_PARAMS=%s", s.config.Params),
fmt.Sprintf("FRAMERATE=%s", s.config.Framerate),
fmt.Sprintf("RELAY_URL=%s", s.config.RelayURL),
},
}, &container.HostConfig{
Binds: []string{
fmt.Sprintf("%s:/home/nestri/.steam/", s.config.GamePath),
},
Resources: container.Resources{
DeviceRequests: deviceRequests,
Devices: devices,
},
SecurityOpt: []string{"label=disable"},
ShmSize: 5368709120, // 5GB
// ShmSize: 1073741824, // 1GB
}, nil, nil, "")
if err != nil {
return fmt.Errorf("failed to create container: %v", err)
}
// Start container
if err := s.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
return fmt.Errorf("failed to start container: %v", err)
}
// Store container ID and update state
s.containerID = resp.ID
s.isRunning = true
// Start logging in a goroutine
go s.streamLogs(ctx)
return nil
}
// Stop stops the Docker container session
func (s *Session) Stop(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.isRunning {
return fmt.Errorf("session is not running")
}
timeout := 30 // seconds
if err := s.client.ContainerStop(ctx, s.containerID, container.StopOptions{Timeout: &timeout}); err != nil {
return fmt.Errorf("failed to stop container: %v", err)
}
if err := s.client.ContainerRemove(ctx, s.containerID, container.RemoveOptions{}); err != nil {
return fmt.Errorf("failed to remove container: %v", err)
}
s.isRunning = false
s.containerID = ""
return nil
}
// IsRunning returns the current state of the session
func (s *Session) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.isRunning
}
// GetContainerID returns the current container ID
func (s *Session) GetContainerID() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.containerID
}
// streamLogs streams container logs to stdout
func (s *Session) streamLogs(ctx context.Context) {
opts := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
}
logs, err := s.client.ContainerLogs(ctx, s.containerID, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting container logs: %v\n", err)
return
}
defer logs.Close()
_, err = io.Copy(os.Stdout, logs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error streaming logs: %v\n", err)
}
}
// VerifyEnvironment checks if all expected environment variables are set correctly in the container
func (s *Session) VerifyEnvironment(ctx context.Context) error {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.isRunning {
return fmt.Errorf("session is not running")
}
// Get container info to verify it's actually running
inspect, err := s.client.ContainerInspect(ctx, s.containerID)
if err != nil {
return fmt.Errorf("failed to inspect container: %v", err)
}
if !inspect.State.Running {
return fmt.Errorf("container is not in running state")
}
// Expected environment variables
expectedEnv := map[string]string{
"NESTRI_ROOM": s.config.Room,
"RESOLUTION": s.config.Resolution,
"FRAMERATE": s.config.Framerate,
"RELAY_URL": s.config.RelayURL,
"NESTRI_PARAMS": s.config.Params,
}
// Get actual environment variables from container
containerEnv := make(map[string]string)
for _, env := range inspect.Config.Env {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
containerEnv[parts[0]] = parts[1]
}
}
// Check each expected variable
var missingVars []string
var mismatchedVars []string
for key, expectedValue := range expectedEnv {
actualValue, exists := containerEnv[key]
if !exists {
missingVars = append(missingVars, key)
} else if actualValue != expectedValue {
mismatchedVars = append(mismatchedVars, fmt.Sprintf("%s (expected: %s, got: %s)",
key, expectedValue, actualValue))
}
}
// Build error message if there are any issues
if len(missingVars) > 0 || len(mismatchedVars) > 0 {
var errorMsg strings.Builder
if len(missingVars) > 0 {
errorMsg.WriteString(fmt.Sprintf("Missing environment variables: %s\n",
strings.Join(missingVars, ", ")))
}
if len(mismatchedVars) > 0 {
errorMsg.WriteString(fmt.Sprintf("Mismatched environment variables: %s",
strings.Join(mismatchedVars, ", ")))
}
return fmt.Errorf(errorMsg.String())
}
return nil
}
// GetEnvironment returns all environment variables in the container
func (s *Session) GetEnvironment(ctx context.Context) (map[string]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.isRunning {
return nil, fmt.Errorf("session is not running")
}
inspect, err := s.client.ContainerInspect(ctx, s.containerID)
if err != nil {
return nil, fmt.Errorf("failed to inspect container: %v", err)
}
env := make(map[string]string)
for _, e := range inspect.Config.Env {
parts := strings.SplitN(e, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
}
}
return env, nil
}

View File

@@ -0,0 +1,76 @@
package session
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"github.com/docker/docker/api/types/container"
)
// ExecResult holds the output from a container command
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
}
func (s *Session) execInContainer(ctx context.Context, cmd []string) (*ExecResult, error) {
execConfig := container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
}
execID, err := s.client.ContainerExecCreate(ctx, s.containerID, execConfig)
if err != nil {
return nil, err
}
resp, err := s.client.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
defer resp.Close()
var outBuf bytes.Buffer
_, err = io.Copy(&outBuf, resp.Reader)
if err != nil {
return nil, err
}
inspect, err := s.client.ContainerExecInspect(ctx, execID.ID)
if err != nil {
return nil, err
}
return &ExecResult{
ExitCode: inspect.ExitCode,
Stdout: outBuf.String(),
}, nil
}
// CheckSteamGames returns the list of installed games in the container
func (s *Session) CheckInstalledSteamGames(ctx context.Context) ([]uint64, error) {
result, err := s.execInContainer(ctx, []string{
"sh", "-c",
"find /home/nestri/.steam/steam/steamapps -name '*.acf' -exec grep -H '\"appid\"' {} \\;",
})
if err != nil {
return nil, fmt.Errorf("failed to check steam games: %v", err)
}
var gameIDs []uint64
for _, line := range strings.Split(result.Stdout, "\n") {
if strings.Contains(line, "appid") {
var id uint64
if _, err := fmt.Sscanf(line, `"appid" "%d"`, &id); err == nil {
gameIDs = append(gameIDs, id)
}
}
}
return gameIDs, nil
}

View File

@@ -0,0 +1,72 @@
package session
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/docker/docker/api/types/container"
)
// detectGPU checks for available GPU type
func detectGPU() GPUType {
// First check for NVIDIA
cmd := exec.Command("nvidia-smi")
if err := cmd.Run(); err == nil {
return GPUNvidia
}
// Check for Intel/AMD GPU by looking for DRI devices
if _, err := os.Stat("/dev/dri"); err == nil {
return GPUIntelAMD
}
return GPUNone
}
// getGPUDeviceRequests returns appropriate device configuration based on GPU type
func getGPUDeviceRequests(gpuType GPUType) ([]container.DeviceRequest, error) {
switch gpuType {
case GPUNvidia:
return []container.DeviceRequest{
{
Driver: "nvidia",
Count: 1,
DeviceIDs: []string{"0"},
Capabilities: [][]string{{"gpu"}},
},
}, nil
case GPUIntelAMD:
return []container.DeviceRequest{}, nil // Empty as we'll handle this in Devices
default:
return nil, fmt.Errorf("no supported GPU detected")
}
}
// getGPUDevices returns appropriate device mappings based on GPU type
func getGPUDevices(gpuType GPUType) []container.DeviceMapping {
if gpuType == GPUIntelAMD {
devices := []container.DeviceMapping{}
// Only look for card and renderD nodes
for _, pattern := range []string{"card[0-9]*", "renderD[0-9]*"} {
matches, err := filepath.Glob(fmt.Sprintf("/dev/dri/%s", pattern))
if err != nil {
continue
}
for _, match := range matches {
// Verify it's a device file
if info, err := os.Stat(match); err == nil && (info.Mode()&os.ModeDevice) != 0 {
devices = append(devices, container.DeviceMapping{
PathOnHost: match,
PathInContainer: match,
CgroupPermissions: "rwm",
})
}
}
}
return devices
}
return nil
}

58
packages/cli/main.go Normal file
View File

@@ -0,0 +1,58 @@
package main
import (
"context"
"nestrilabs/cli/internal/session"
"github.com/charmbracelet/log"
)
func main() {
// err := cmd.Execute()
// if err != nil {
// log.Error("Error running the cmd command", "err", err)
// }
ctx := context.Background()
config := &session.SessionConfig{
Room: "victortest",
Resolution: "1920x1080",
Framerate: "60",
RelayURL: "https://relay.dathorse.com",
Params: "--verbose=true --video-codec=h264 --video-bitrate=4000 --video-bitrate-max=6000 --gpu-card-path=/dev/dri/card1",
GamePath: "/path/to/your/game",
}
sess, err := session.NewSession(config)
if err != nil {
log.Error("Failed to create session", "err", err)
}
// Start the session
if err := sess.Start(ctx); err != nil {
log.Error("Failed to start session", "err", err)
}
// Check if it's running
if sess.IsRunning() {
log.Info("Session is running with container ID", "containerId", sess.GetContainerID())
}
env, err := sess.GetEnvironment(ctx)
if err != nil {
log.Printf("Failed to get environment: %v", err)
} else {
for key, value := range env {
log.Info("Found this environment variables", key, value)
}
}
// Let it run for a while
// time.Sleep(time.Second * 50)
// Stop the session
if err := sess.Stop(ctx); err != nil {
log.Error("Failed to stop session", "err", err)
}
}

View File

@@ -1,73 +0,0 @@
// image-brightness-analyzer.js
export class ImageBrightnessAnalyzer {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D ;
constructor() {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d')!;
}
analyze(imgElement: HTMLImageElement) {
if (!(imgElement instanceof HTMLImageElement)) {
throw new Error('Input must be an HTMLImageElement');
}
this.canvas.width = imgElement.width;
this.canvas.height = imgElement.height;
this.ctx.drawImage(imgElement, 0, 0);
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const data = imageData.data;
let brightestPixel = { value: 0, x: 0, y: 0 };
let dullestPixel = { value: 765, x: 0, y: 0 }; // 765 is the max value (255 * 3)
for (let y = 0; y < this.canvas.height; y++) {
for (let x = 0; x < this.canvas.width; x++) {
const index = (y * this.canvas.width + x) * 4;
const brightness = data[index] + data[index + 1] + data[index + 2];
if (brightness > brightestPixel.value) {
brightestPixel = { value: brightness, x, y };
}
if (brightness < dullestPixel.value) {
dullestPixel = { value: brightness, x, y };
}
}
}
return {
brightest: {
x: brightestPixel.x,
y: brightestPixel.y,
color: this.getPixelColor(data, brightestPixel.x, brightestPixel.y)
},
dullest: {
x: dullestPixel.x,
y: dullestPixel.y,
color: this.getPixelColor(data, dullestPixel.x, dullestPixel.y)
}
};
}
getPixelColor(data: any[] | Uint8ClampedArray, x: number, y: number) {
const index = (y * this.canvas.width + x) * 4;
return {
r: data[index],
g: data[index + 1],
b: data[index + 2]
};
}
}
// // Export the class for use in browser environments
// if (typeof window !== 'undefined') {
// window.ImageBrightnessAnalyzer = ImageBrightnessAnalyzer;
// }
// // Export for module environments (if using a bundler)
// if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
// module.exports = ImageBrightnessAnalyzer;
// }

View File

@@ -1 +0,0 @@
export * from "./image-brightness-analyzer.ts"

View File

@@ -0,0 +1,35 @@
// Docs: https://www.instantdb.com/docs/permissions
import type { InstantRules } from "@instantdb/core";
const rules = {
/**
* Welcome to Instant's permission system!
* Right now your rules are empty. To start filling them in, check out the docs:
* https://www.instantdb.com/docs/permissions
*
* Here's an example to give you a feel:
* posts: {
* allow: {
* view: "true",
* create: "isOwner",
* update: "isOwner",
* delete: "isOwner",
* },
* bind: ["isOwner", "auth.id != null && auth.id == data.ownerId"],
* },
*/
"$default": {
"allow": {
"$default": "false"
}
},
machines: {
allow: {
"$default": "isOwner",
},
bind: ["isOwner", "auth.id != null && auth.id == data.ownerID"],
}
} satisfies InstantRules;
export default rules;

View File

@@ -0,0 +1,80 @@
import { i } from "@instantdb/core";
const _schema = i.schema({
// This section lets you define entities: think `posts`, `comments`, etc
// Take a look at the docs to learn more:
// https://www.instantdb.com/docs/modeling-data#2-attributes
entities: {
$users: i.entity({
email: i.string().unique().indexed(),
}),
// This is here because the $users entity has no more than 1 property; email
// profiles: i.entity({
// name: i.string(),
// location: i.string(),
// createdAt: i.date(),
// deletedAt: i.date().optional()
// }),
machines: i.entity({
hostname: i.string(),
location: i.string(),
fingerprint: i.string().indexed(),
createdAt: i.date(),
deletedAt: i.date().optional().indexed()
}),
// teams: i.entity({
// name: i.string(),
// type: i.string(), // "Personal" or "Family"
// createdAt: i.date(),
// deletedAt: i.date().optional()
// }),
// subscriptions: i.entity({
// quantity: i.number(),
// polarOrderID: i.string(),
// frequency: i.string(),
// next: i.date().optional(),
// }),
// productVariants: i.entity({
// name: i.string(),
// price: i.number()
// })
},
// links: {
// userProfiles: {
// forward: { on: 'profiles', has: 'one', label: 'owner' },
// reverse: { on: '$users', has: 'one', label: 'profile' },
// },
// machineOwners: {
// forward: { on: 'machines', has: 'one', label: 'owner' },
// reverse: { on: '$users', has: 'many', label: 'machinesOwned' },
// },
// machineTeams: {
// forward: { on: 'machines', has: 'one', label: 'team' },
// reverse: { on: 'teams', has: 'many', label: 'machines' },
// },
// userTeams: {
// forward: { on: 'teams', has: 'one', label: 'owner' },
// reverse: { on: '$users', has: 'many', label: 'teamsOwned' },
// },
// teamMembers: {
// forward: { on: 'teams', has: 'many', label: 'members' },
// reverse: { on: '$users', has: 'many', label: 'teams' },
// },
// subscribedProduct: {
// forward: { on: "subscriptions", has: "one", label: "productVariant" },
// reverse: { on: "productVariants", has: "many", label: "subscriptions" }
// },
// subscribedUser: {
// forward: { on: "subscriptions", has: "one", label: "owner" },
// reverse: { on: "$users", has: "many", label: "subscriptions" }
// }
// }
});
// This helps Typescript display nicer intellisense
type _AppSchema = typeof _schema;
interface AppSchema extends _AppSchema { }
const schema: AppSchema = _schema;
export type { AppSchema };
export default schema;

View File

@@ -1,13 +1,20 @@
{
"name": "@nestri/core",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"exports":{
".":"./index.ts"
"type": "module",
"exports": {
"./*": "./src/*.ts"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240529.0",
"wrangler": "^3.57.2"
"@tsconfig/node20": "^20.1.4",
"loops": "^3.4.1",
"ulid": "^2.3.0",
"uuid": "^11.0.3",
"zod": "^3.24.1",
"zod-openapi": "^4.2.2"
},
"dependencies": {
"@instantdb/admin": "^0.17.3"
}
}
}

View File

@@ -0,0 +1,85 @@
import { createContext } from "./context";
import { VisibleError } from "./error";
export interface UserActor {
type: "user";
properties: {
accessToken: string;
userID: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface DeviceActor {
type: "device";
properties: {
fingerprint: string;
id: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface PublicActor {
type: "public";
properties: {};
}
type Actor = UserActor | PublicActor | DeviceActor;
export const ActorContext = createContext<Actor>();
export function useCurrentUser() {
const actor = ActorContext.use();
if (actor.type === "user") return {
id:actor.properties.userID,
token: actor.properties.accessToken
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useCurrentDevice() {
const actor = ActorContext.use();
if (actor.type === "device") return {
fingerprint:actor.properties.fingerprint,
id: actor.properties.id
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useActor() {
try {
return ActorContext.use();
} catch {
return { type: "public", properties: {} } as PublicActor;
}
}
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type)
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
return actor as Extract<Actor, { type: T }>;
}

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import "zod-openapi/extend";
export module Common {
export const IdDescription = `Unique object identifier.
The format and length of IDs may change over time.`;
}

View File

@@ -0,0 +1,17 @@
import { AsyncLocalStorage } from "node:async_hooks";
export function createContext<T>() {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new Error("No context available");
}
return result;
},
with<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
},
};
}

View File

@@ -0,0 +1,12 @@
import { Resource } from "sst";
import { init } from "@instantdb/admin";
import schema from "../instant.schema";
const databaseClient = () => init({
appId: Resource.InstantAppId.value,
adminToken: Resource.InstantAdminToken.value,
schema
})
export default databaseClient

View File

@@ -0,0 +1,25 @@
import { LoopsClient } from "loops";
import { Resource } from "sst/resource"
export namespace Email {
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
export async function send(
to: string,
body: string,
) {
try {
await Client().sendTransactionalEmail(
{
transactionalId: "cm58pdf8d03upb5ecirnmvrfb",
email: to,
dataVariables: {
logincode: body
}
}
);
} catch (error) {
console.log("error sending email", error)
}
}
}

View File

@@ -0,0 +1,9 @@
export class VisibleError extends Error {
constructor(
public kind: "input" | "auth",
public code: string,
public message: string,
) {
super(message);
}
}

View File

@@ -0,0 +1,45 @@
export module Examples {
export const User = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
email: "john@example.com",
};
export const Machine = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "desktopeuo8vsf",
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
location: "KE, AF"
}
// export const Team = {
// id: createID(),
// name: "Jane's Family",
// type: "Family"
// }
// export const ProductVariant = {
// id: createID(),
// name: "FamilySM",
// price: 10,
// };
// export const Product = {
// id: createID(),
// name: "Family",
// description: "The ideal subscription tier for dedicated gamers who crave more flexibility and social gaming experiences.",
// variants: [ProductVariant],
// subscription: "allowed" as const,
// };
// export const Subscription = {
// id: createID(),
// productVariant: ProductVariant,
// quantity: 1,
// polarOrderID: createID(),
// frequency: "monthly" as const,
// next: new Date("2024-02-01 19:36:19.000").getTime(),
// owner: User
// };
}

View File

@@ -0,0 +1,140 @@
import { z } from "zod"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database"
import { useCurrentUser } from "../actor";
import { id as createID } from "@instantdb/admin";
export module Machine {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Machine.id,
}),
hostname: z.string().openapi({
description: "Hostname of the machine",
example: Examples.Machine.hostname,
}),
fingerprint: z.string().openapi({
description: "The machine's fingerprint, derived from the machine's Linux machine ID.",
example: Examples.Machine.fingerprint,
}),
location: z.string().openapi({
description: "The machine's approximate location; country and continent.",
example: Examples.Machine.location,
})
})
.openapi({
ref: "Machine",
description: "A machine running on the Nestri network.",
example: Examples.Machine,
});
export const create = fn(z.object({
fingerprint: z.string(),
hostname: z.string(),
location: z.string()
}), async (input) => {
const id = createID()
const now = new Date().getTime()
const db = databaseClient()
await db.transact(
db.tx.machines[id]!.update({
fingerprint: input.fingerprint,
hostname: input.hostname,
location: input.location,
createdAt: now,
})
)
return id
})
export const remove = fn(z.string(), async (id) => {
const now = new Date().getTime()
// const device = useCurrentDevice()
// const db = databaseClient()
// if (device.id) { // the machine can delete itself
// await db.transact(db.tx.machines[device.id]!.update({ deletedAt: now }))
// } else {// the user can delete it manually
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
await db.transact(db.tx.machines[id]!.update({ deletedAt: now }))
// }
return "ok"
})
export const fromID = fn(z.string(), async (id) => {
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
const query = {
machines: {
$: {
where: {
id: id,
deletedAt: { $isNull: true }
}
}
}
}
const res = await db.query(query)
return res.machines[0]
})
export const fromFingerprint = fn(z.string(), async (input) => {
const db = databaseClient()
const query = {
machines: {
$: {
where: {
fingerprint: input,
deletedAt: { $isNull: true }
}
}
}
}
const res = await db.query(query)
return res.machines[0]
})
export const list = async () => {
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
const query = {
$users: {
$: { where: { id: user.id } },
machines: {
$: {
deletedAt: { $isNull: true }
}
}
},
}
const res = await db.query(query)
return res.$users[0]?.machines
}
export const link = fn(z.object({
machineId: z.string()
}), async (input) => {
const user = useCurrentUser()
const db = databaseClient()
await db.transact(db.tx.machines[input.machineId]!.link({ owner: user.id }))
return "ok"
})
}

View File

@@ -0,0 +1,48 @@
import databaseClient from "../database"
import { z } from "zod"
import { Common } from "../common";
import { createID, fn } from "../utils";
import { Examples } from "../examples";
export module Team {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Team.id,
}),
name: z.string().openapi({
description: "Name of the machine",
example: Examples.Team.name,
}),
type: z.string().nullable().openapi({
description: "Whether this is a personal or family type of team",
example: Examples.Team.type,
})
})
.openapi({
ref: "Team",
description: "A group of Nestri user's who share the same machine",
example: Examples.Team,
});
export const create = fn(z.object({
name: z.string(),
type: z.enum(["personal", "family"]),
owner: z.string(),
}), async (input) => {
const id = createID("machine")
const now = new Date().getTime()
const db = databaseClient()
await db.transact(db.tx.teams[id]!.update({
name: input.name,
type: input.type,
createdAt: now
}).link({
owner: input.owner,
}))
return id
})
}

View File

@@ -0,0 +1,17 @@
export interface CloudflareCF {
colo: string;
continent: string;
country: string,
city: string;
region: string;
longitude: number;
latitude: number;
metroCode: string;
postalCode: string;
timezone: string;
regionCode: number;
}
export interface CFRequest extends Request {
cf: CloudflareCF
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
export module User {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.User.id,
}),
email: z.string().nullable().openapi({
description: "Email address of the user.",
example: Examples.User.email,
}),
})
.openapi({
ref: "User",
description: "A Nestri console user.",
example: Examples.User,
});
export const fromEmail = fn(z.string(), async (email) => {
const db = databaseClient()
const res = await db.auth.getUser({ email })
return res
})
export const create = fn(z.string(), async (email) => {
const db = databaseClient()
const token = await db.auth.createToken(email)
return token
})
}

View File

@@ -0,0 +1,13 @@
import { ZodSchema, z } from "zod";
export function fn<
Arg1 extends ZodSchema,
Callback extends (arg1: z.output<Arg1>) => any,
>(arg1: Arg1, cb: Callback) {
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
const parsed = arg1.parse(input);
return cb.apply(cb, [parsed as any]);
};
result.schema = arg1;
return result;
}

View File

@@ -0,0 +1 @@
export * from "./fn"

42
packages/core/sst-env.d.ts vendored Normal file
View File

@@ -0,0 +1,42 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"module": "esnext",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"noUncheckedIndexedAccess": true,
}
}

View File

@@ -1,6 +0,0 @@
# https://github.com/iximiuz/docker-to-linux/blob/master/Dockerfile
FROM amd64/debian:bullseye
LABEL com.iximiuz-project="docker-to-linux"
RUN apt-get -y update
RUN apt-get -y install extlinux fdisk

View File

@@ -1,15 +0,0 @@
# Docker to RootFS
We are building the rootfs as a docker image, mainly for consistency and ease of use.
So, to convert the Docker image to a rootfs good enough to run inside CrosVM we use this script
Run it like so:
```bash
./make your-docker-image
```
TODO:
1. Make sure the docker image name passed in exists
2. Reduce the dependencies of this script to 1 (If possible)
3. Extract not only the rootfs, but also kernel and initrd
4. Add a way to pass in the size of the rootfs the user wants

View File

@@ -1,54 +0,0 @@
#!/bin/bash
set -e
UID_HOST=$1
GID_HOST=$2
VM_DISK_SIZE_MB=$3
echo_blue() {
local font_blue="\033[94m"
local font_bold="\033[1m"
local font_end="\033[0m"
echo -e "\n${font_blue}${font_bold}${1}${font_end}"
}
echo_blue "[Create disk image]"
[ -z "${VM_DISK_SIZE_MB}" ] && VM_DISK_SIZE_MB=1024
VM_DISK_SIZE_SECTOR=$(expr $VM_DISK_SIZE_MB \* 1024 \* 1024 / 512)
dd if=/dev/zero of=/os/${DISTR}.img bs=${VM_DISK_SIZE_SECTOR} count=512
echo_blue "[Make partition]"
echo "type=83,bootable" | sfdisk /os/${DISTR}.img
echo_blue "\n[Format partition with ext4]"
losetup -D
LOOPDEVICE=$(losetup -f)
echo -e "\n[Using ${LOOPDEVICE} loop device]"
losetup -o $(expr 512 \* 2048) ${LOOPDEVICE} /os/${DISTR}.img
mkfs.ext4 ${LOOPDEVICE}
echo_blue "[Copy ${DISTR} directory structure to partition]"
mkdir -p /os/mnt
mount -t auto ${LOOPDEVICE} /os/mnt/
cp -a /os/${DISTR}.dir/. /os/mnt/
echo_blue "[Setup extlinux]"
extlinux --install /os/mnt/boot/
cp /os/syslinux.cfg /os/mnt/boot/syslinux.cfg
rm /os/mnt/.dockerenv
echo_blue "[Unmount]"
umount /os/mnt
losetup -D
echo_blue "[Write syslinux MBR]"
dd if=/usr/lib/syslinux/mbr/mbr.bin of=/os/${DISTR}.img bs=440 count=1 conv=notrunc
#echo_blue "[Convert to qcow2]"
#qemu-img convert -c /os/${DISTR}.img -O qcow2 /os/${DISTR}.qcow2
[ "${UID_HOST}" -a "${GID_HOST}" ] && chown ${UID_HOST}:${GID_HOST} /os/${DISTR}.img
rm -r /os/${DISTR}.dir /os/${DISTR}.tar

View File

@@ -1,112 +0,0 @@
#!/bin/bash
# Define colors
COL_RED="\033[0;31m"
COL_GRN="\033[0;32m"
COL_END="\033[0m"
# Get current user and group IDs
USER_ID=$(id -u)
GROUP_ID=$(id -g)
# Set default disk size
VM_DISK_SIZE_MB=${VM_DISK_SIZE_MB:-4096}
# Set repository name
REPO="nestri"
# Function to create a tar archive from a Docker image
create_tar() {
local name=$1
echo -e "\n${COL_GRN}[Dump $name directory structure to tar archive]${COL_END}"
docker export -o $name.tar $(docker run -d $name /bin/true)
}
# Function to extract a tar archive
extract_tar() {
local name=$1
echo -e "\n${COL_GRN}[Extract $name tar archive]${COL_END}"
docker run -it \
-v $(pwd):/os:rw \
$REPO/builder bash -c "mkdir -p /os/$name.dir && tar -C /os/$name.dir --numeric-owner -xf /os/$name.tar"
}
# Function to create a disk image
create_image() {
local name=$1
echo -e "\n${COL_GRN}[Create $name disk image]${COL_END}"
docker run -it \
-v $(pwd):/os:rw \
-e DISTR=$name \
--privileged \
--cap-add SYS_ADMIN \
$REPO/builder bash /os/create_image.sh $USER_ID $GROUP_ID $VM_DISK_SIZE_MB
}
# Function to ensure builder is ready
ensure_builder() {
echo -e "\n${COL_GRN}[Ensure builder is ready]${COL_END}"
if [ "$(docker images -q $REPO/builder)" = '' ]; then
docker build -f Dockerfile -t $REPO/builder .
fi
}
# Function to run builder interactively
run_builder_interactive() {
docker run -it \
-v $(pwd):/os:rw \
--cap-add SYS_ADMIN \
$REPO/builder bash
}
# Function to clean up
clean_up() {
echo -e "\n${COL_GRN}[Remove leftovers]${COL_END}"
rm -rf mnt debian.* alpine.* ubuntu.*
clean_docker_procs
clean_docker_images
}
# Function to clean Docker processes
clean_docker_procs() {
echo -e "\n${COL_GRN}[Remove Docker Processes]${COL_END}"
if [ "$(docker ps -qa -f=label=com.iximiuz-project=$REPO)" != '' ]; then
docker rm $(docker ps -qa -f=label=com.iximiuz-project=$REPO)
else
echo "<noop>"
fi
}
# Function to clean Docker images
clean_docker_images() {
echo -e "\n${COL_GRN}[Remove Docker Images]${COL_END}"
if [ "$(docker images -q $REPO/*)" != '' ]; then
docker rmi $(docker images -q $REPO/*)
else
echo "<noop>"
fi
}
# Main script
if [ $# -lt 1 ]; then
echo "Usage: $0 <image_name> [clean|builder-interactive]"
exit 1
fi
IMAGE_NAME=$1
case $2 in
builder-interactive)
run_builder_interactive
;;
clean)
clean_up
;;
esac
ensure_builder
create_tar $IMAGE_NAME
extract_tar $IMAGE_NAME
create_image $IMAGE_NAME
# Extract kernel `` virt-builder --get-kernel "${IMAGE_NAME}.img" -o . ``

View File

@@ -1,5 +0,0 @@
DEFAULT linux
SAY Now booting the kernel from SYSLINUX...
LABEL linux
KERNEL /vmlinuz
APPEND rw root=/dev/sda1 initrd=/initrd.img

View File

@@ -1,3 +0,0 @@
# `@turbo/eslint-config`
Collection of internal eslint configurations.

View File

@@ -1,34 +0,0 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["eslint:recommended", "prettier", "turbo"],
plugins: ["only-warn"],
globals: {
React: true,
JSX: true,
},
env: {
node: true,
},
settings: {
"import/resolver": {
typescript: {
project,
},
},
},
ignorePatterns: [
// Ignore dotfiles
".*.js",
"node_modules/",
"dist/",
],
overrides: [
{
files: ["*.js?(x)", "*.ts?(x)"],
},
],
};

View File

@@ -1,35 +0,0 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: [
"eslint:recommended",
"prettier",
require.resolve("@vercel/style-guide/eslint/next"),
"turbo",
],
globals: {
React: true,
JSX: true,
},
env: {
node: true,
browser: true,
},
plugins: ["only-warn"],
settings: {
"import/resolver": {
typescript: {
project,
},
},
},
ignorePatterns: [
// Ignore dotfiles
".*.js",
"node_modules/",
],
overrides: [{ files: ["*.js?(x)", "*.ts?(x)"] }],
};

View File

@@ -1,25 +0,0 @@
{
"name": "@nestri/eslint-config",
"version": "0.0.0",
"private": true,
"files": [
"library.js",
"next.js",
"qwik.js",
"react-internal.js"
],
"devDependencies": {
"@vercel/style-guide": "^5.2.0",
"eslint-config-turbo": "^2.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-only-warn": "^1.1.0",
"@types/eslint": "8.56.10",
"@types/node": "20.14.11",
"@typescript-eslint/eslint-plugin": "7.16.1",
"@typescript-eslint/parser": "7.16.1",
"eslint": "8.57.0",
"eslint-plugin-qwik": "^1.8.0",
"prettier": "3.3.3",
"typescript": "5.4.5"
}
}

View File

@@ -1,50 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:qwik/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
ecmaVersion: 2021,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
// Warn when an unused variable doesn't start with an underscore
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"prefer-spread": "off",
"no-case-declarations": "off",
"no-console": "off",
"qwik/no-use-visible-task": "off",
"@typescript-eslint/consistent-type-imports": "warn",
"@typescript-eslint/no-unnecessary-condition": "warn",
},
};

View File

@@ -1,39 +0,0 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
/*
* This is a custom ESLint configuration for use with
* internal (bundled by their consumer) libraries
* that utilize React.
*/
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["eslint:recommended", "prettier", "turbo"],
plugins: ["only-warn"],
globals: {
React: true,
JSX: true,
},
env: {
browser: true,
},
settings: {
"import/resolver": {
typescript: {
project,
},
},
},
ignorePatterns: [
// Ignore dotfiles
".*.js",
"node_modules/",
"dist/",
],
overrides: [
// Force ESLint to detect .tsx files
{ files: ["*.js?(x)", "*.ts?(x)"] },
],
};

175
packages/functions/.gitignore vendored Normal file
View File

@@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

Some files were not shown because too many files have changed in this diff Show More