Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions configs/client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ version: development
auth:
api: http://localhost:2024
onet:
enabled: true
api: http://localhost:9400
phantom:
enabled: false
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
225 changes: 225 additions & 0 deletions seed.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
type Response<Data> = { 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 <Data>(
method: string,
pathname: string,
{ body, headers = {} }: { body?: unknown; headers?: Session } = {},
): Promise<Response<Data>> => {
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<Session> => {
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<string> => {
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<string> => {
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<string, unknown> };

const updateConfig = async (path: string, update: (config: Config) => void) => {
try {
const config = await readYaml<Config>(path);
update(config);
await writeYaml(path, config);
ok(`${path} updated`);
} catch {
warn(`${path} not found, run 'deno task setup' first`);
}
};

const seed = await readYaml<Seed>(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")}
`);
13 changes: 13 additions & 0 deletions seed.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 14 additions & 34 deletions setup.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<boolean> => {
try {
const { success } = await new Deno.Command(cmd, {
Expand All @@ -73,17 +70,6 @@ const checkRequirements = async () => {
}
};

const loadEnv = async (): Promise<Record<string, string>> => {
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);
Expand All @@ -105,9 +91,6 @@ const cloneRepos = async (reposDir: string) => {
}
};

const readYaml = async (path: string): Promise<Record<string, unknown>> =>
parseYaml(await Deno.readTextFile(path)) ?? {};

const flatten = (
object: Record<string, unknown>,
prefix = "",
Expand All @@ -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<string, unknown>, path) }
: { ...flat, [path]: value };
}, {});

Expand Down Expand Up @@ -165,17 +148,14 @@ 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);

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
`);
36 changes: 36 additions & 0 deletions utils.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> => {
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<string, string>) =>
resolve(ROOT, env.REPOS_DIR ?? "..");

export const readYaml = async <Data = Record<string, unknown>>(
path: string,
): Promise<Data> => (parseYaml(await Deno.readTextFile(path)) ?? {}) as Data;

export const writeYaml = (path: string, data: unknown) =>
Deno.writeTextFile(path, stringifyYaml(data, { lineWidth: -1 }));
Loading