From 2fea1aa78481422dd2c077691d9e39ea477d9b8c Mon Sep 17 00:00:00 2001 From: Alberto Quiles Date: Sun, 4 Oct 2026 12:05:34 +0200 Subject: [PATCH] feat: add seed script - fix #7 --- README.md | 2 + configs/client.yml | 1 + deno.json | 1 + seed.ts | 225 +++++++++++++++++++++++++++++++++++++++++++++ seed.yml | 13 +++ setup.ts | 48 +++------- utils.ts | 36 ++++++++ 7 files changed, 292 insertions(+), 34 deletions(-) create mode 100644 seed.ts create mode 100644 seed.yml create mode 100644 utils.ts diff --git a/README.md b/README.md index 86672c6..a47cd9c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ openhotel/ ```bash deno task setup # clone missing repos and create dev configs +deno task up auth # start auth +deno task seed # create users, hotel and tokens (seed.yml) deno task up # everything (in background) deno task up auth client # some services deno task logs # logs of all services diff --git a/configs/client.yml b/configs/client.yml index 4196a7d..9172b37 100644 --- a/configs/client.yml +++ b/configs/client.yml @@ -2,6 +2,7 @@ version: development auth: api: http://localhost:2024 onet: + enabled: true api: http://localhost:9400 phantom: enabled: false diff --git a/deno.json b/deno.json index 2861012..90ec6f7 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "tasks": { "setup": "deno run --allow-read --allow-write --allow-run=git,docker setup.ts", + "seed": "deno run --allow-read --allow-write --allow-net=localhost:2024 seed.ts", "up": "docker compose up -d --build", "logs": "docker compose logs -f --tail=100", "down": "docker compose down" diff --git a/seed.ts b/seed.ts new file mode 100644 index 0000000..5302733 --- /dev/null +++ b/seed.ts @@ -0,0 +1,225 @@ +import { join } from "@std/path"; +import { + fail, + getReposDir, + loadEnv, + ok, + readYaml, + ROOT, + warn, + writeYaml, +} from "./utils.ts"; + +type User = { username: string; email: string; password: string }; +type Seed = { users: User[]; hotel: { name: string } }; +type Session = Record; +type Response = { status: number; data?: Data; message?: string }; + +const AUTH_API = "http://localhost:2024/api/v3"; +const FINGERPRINT = "devkit-seed"; +const INTEGRATION = { + name: "devkit", + redirectUrl: "http://localhost:1994", + type: "client", +}; +const ONET_TOKEN_LABEL = "onet"; + +const request = async ( + method: string, + pathname: string, + { body, headers = {} }: { body?: unknown; headers?: Session } = {}, +): Promise> => { + const response = await fetch(`${AUTH_API}${pathname}`, { + method, + headers: { fingerprint: FINGERPRINT, ...headers }, + body: body ? JSON.stringify(body) : undefined, + }); + + return response.json(); +}; + +const checkAuth = async () => { + try { + await request("GET", "/_/version"); + } catch { + fail(`auth is not running on ${AUTH_API}`); + } +}; + +const register = async ({ username, email, password }: User) => { + const { status, message } = await request("POST", "/account/register", { + body: { + username, + email, + password, + rePassword: password, + languages: ["en"], + }, + }); + + if (status === 200) ok(`${username}: registered`); + else if (status === 409) ok(`${username}: already registered`); + else fail(`${username}: register failed (${status} ${message ?? ""})`); +}; + +const login = async ({ username, email, password }: User): Promise => { + const { status, data, message } = await request<{ + accountId: string; + token: string; + }>("POST", "/account/login", { body: { email, password } }); + + if (status !== 200 || !data) { + return fail(`${username}: login failed (${status} ${message ?? ""})`); + } + + return { + "account-id": data.accountId, + token: data.token, + }; +}; + +const setAdmin = async (user: User, session: Session) => { + const { status } = await request("POST", "/admin", { headers: session }); + + if (status === 200) ok(`${user.username}: is now admin`); + else ok(`auth already has an admin`); +}; + +const getLicenseToken = async ( + name: string, + session: Session, +): Promise => { + type Hotel = { + hotelId: string; + name: string; + integrations: { integrationId: string; name: string }[]; + }; + + const getHotel = async () => { + const { data } = await request<{ hotels: Hotel[] }>( + "GET", + "/user/@me/hotel", + { headers: session }, + ); + + return data?.hotels.find((hotel) => hotel.name === name); + }; + + let hotel = await getHotel(); + if (!hotel) { + const { status } = await request("POST", "/user/@me/hotel", { + headers: session, + body: { name, public: false }, + }); + + if (status !== 200) fail(`hotel: create failed (${status})`); + + hotel = (await getHotel())!; + ok(`hotel: '${name}' created`); + } else { + ok(`hotel: '${name}' already exists`); + } + + let integration = hotel.integrations.find( + ({ name }) => name === INTEGRATION.name, + ); + if (!integration) { + const { status, data } = await request<{ integrationId: string }>( + "POST", + "/user/@me/hotel/integration", + { headers: session, body: { hotelId: hotel.hotelId, ...INTEGRATION } }, + ); + + if (status !== 200 || !data) fail(`hotel: integration failed (${status})`); + + integration = { + integrationId: data!.integrationId, + name: INTEGRATION.name, + }; + } + + const { data } = await request<{ token: string }>( + "GET", + `/user/@me/hotel/integration?hotelId=${hotel.hotelId}&integrationId=${integration.integrationId}`, + { headers: session }, + ); + if (!data?.token) { + fail("hotel: license failed"); + } + + ok("hotel: license generated"); + return data!.token; +}; + +const getOnetToken = async (session: Session): Promise => { + const { status, data } = await request<{ + tokens: { id: string; label: string }[]; + }>("GET", "/admin/tokens", { headers: session }); + + if (status !== 200) { + fail(`onet: listing tokens failed (${status}), not admin?`); + } + + for (const { id } of data!.tokens.filter( + ({ label }) => label === ONET_TOKEN_LABEL, + )) { + await request("DELETE", `/admin/tokens?id=${id}`, { headers: session }); + } + + const { data: created } = await request<{ token: string }>( + "POST", + "/admin/tokens", + { headers: session, body: { label: ONET_TOKEN_LABEL } }, + ); + + if (!created?.token) fail("onet: token creation failed"); + + ok("onet: token generated"); + return created!.token; +}; + +type Config = { auth?: Record }; + +const updateConfig = async (path: string, update: (config: Config) => void) => { + try { + const config = await readYaml(path); + update(config); + await writeYaml(path, config); + ok(`${path} updated`); + } catch { + warn(`${path} not found, run 'deno task setup' first`); + } +}; + +const seed = await readYaml(join(ROOT, "seed.yml")); +const reposDir = getReposDir(await loadEnv()); + +await checkAuth(); + +const sessions: Session[] = []; +for (const user of seed.users) { + await register(user); + const session = await login(user); + sessions.push(session); +} + +const [owner] = seed.users; +const [ownerSession] = sessions; +await setAdmin(owner, ownerSession); + +const licenseToken = await getLicenseToken(seed.hotel.name, ownerSession); +const onetToken = await getOnetToken(ownerSession); + +await updateConfig(join(reposDir, "openhotel/app/server/config.yml"), (c) => { + c.auth = { ...c.auth, enabled: true, licenseToken }; +}); +await updateConfig(join(reposDir, "onet/config.yml"), (c) => { + c.auth = { ...c.auth, token: onetToken }; +}); + +console.log(` +Ready! Restart the services to use the new tokens: + +Users (password): +${seed.users.map(({ email, password }) => ` - ${email} (${password})`).join("\n")} +`); diff --git a/seed.yml b/seed.yml new file mode 100644 index 0000000..a2b2d11 --- /dev/null +++ b/seed.yml @@ -0,0 +1,13 @@ +users: + - username: admin + email: admin@openhotel.local + password: admin1234 + - username: pagoru + email: pagoru@openhotel.local + password: pagoru1234 + - username: alqubo + email: alqubo@openhotel.local + password: alqubo1234 + +hotel: + name: Devkit Hotel diff --git a/setup.ts b/setup.ts index 7b98c16..ba054ac 100644 --- a/setup.ts +++ b/setup.ts @@ -1,7 +1,14 @@ -import { parse as parseEnv } from "@std/dotenv"; -import { parse as parseYaml } from "@std/yaml"; import { exists } from "@std/fs"; -import { dirname, join, resolve } from "@std/path"; +import { dirname, join } from "@std/path"; +import { + fail, + getReposDir, + loadEnv, + ok, + readYaml, + ROOT, + warn, +} from "./utils.ts"; type Service = { name: string; @@ -42,16 +49,6 @@ const SERVICES: Service[] = [ }, ]; -const ROOT = import.meta.dirname!; - -const ok = (message: string) => console.log(`%c✔ ${message}`, "color: green"); -const warn = (message: string) => - console.log(`%c⚠ ${message}`, "color: yellow"); -const fail = (message: string) => { - console.log(`%c✘ ${message}`, "color: red"); - Deno.exit(1); -}; - const run = async (cmd: string, ...args: string[]): Promise => { try { const { success } = await new Deno.Command(cmd, { @@ -73,17 +70,6 @@ const checkRequirements = async () => { } }; -const loadEnv = async (): Promise> => { - const envPath = join(ROOT, ".env"); - - if (!(await exists(envPath))) { - await Deno.copyFile(join(ROOT, ".env.example"), envPath); - ok(".env created from .env.example"); - } - - return parseEnv(await Deno.readTextFile(envPath)); -}; - const cloneRepos = async (reposDir: string) => { for (const { repo } of SERVICES) { const repoPath = join(reposDir, repo); @@ -105,9 +91,6 @@ const cloneRepos = async (reposDir: string) => { } }; -const readYaml = async (path: string): Promise> => - parseYaml(await Deno.readTextFile(path)) ?? {}; - const flatten = ( object: Record, prefix = "", @@ -116,7 +99,7 @@ const flatten = ( const path = prefix ? `${prefix}.${key}` : key; return typeof value === "object" && value !== null && !Array.isArray(value) - ? { ...flat, ...flatten(value, path) } + ? { ...flat, ...flatten(value as Record, path) } : { ...flat, [path]: value }; }, {}); @@ -165,7 +148,7 @@ const copyConfigs = async (reposDir: string) => { await checkRequirements(); const env = await loadEnv(); -const reposDir = resolve(ROOT, env.REPOS_DIR ?? ".."); +const reposDir = getReposDir(env); await cloneRepos(reposDir); await copyConfigs(reposDir); @@ -173,9 +156,6 @@ await copyConfigs(reposDir); console.log(` Ready! Next steps: 1. deno task up auth - 2. Sign up at http://localhost:2024 and create a hotel - 3. Fill the tokens: - - openhotel/app/server/config.yml → auth.enabled: true, auth.licenseToken - - onet/config.yml → auth.token - 4. deno task up + 2. deno task seed + 3. deno task up `); diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..0018874 --- /dev/null +++ b/utils.ts @@ -0,0 +1,36 @@ +import { parse as parseEnv } from "@std/dotenv"; +import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; +import { exists } from "@std/fs"; +import { join, resolve } from "@std/path"; + +export const ROOT = import.meta.dirname!; + +export const ok = (message: string) => + console.log(`%c✔ ${message}`, "color: green"); +export const warn = (message: string) => + console.log(`%c⚠ ${message}`, "color: yellow"); +export const fail = (message: string): never => { + console.log(`%c✘ ${message}`, "color: red"); + Deno.exit(1); +}; + +export const loadEnv = async (): Promise> => { + const envPath = join(ROOT, ".env"); + + if (!(await exists(envPath))) { + await Deno.copyFile(join(ROOT, ".env.example"), envPath); + ok(".env created from .env.example"); + } + + return parseEnv(await Deno.readTextFile(envPath)); +}; + +export const getReposDir = (env: Record) => + resolve(ROOT, env.REPOS_DIR ?? ".."); + +export const readYaml = async >( + path: string, +): Promise => (parseYaml(await Deno.readTextFile(path)) ?? {}) as Data; + +export const writeYaml = (path: string, data: unknown) => + Deno.writeTextFile(path, stringifyYaml(data, { lineWidth: -1 }));