diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0988099
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+
+.env.local
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..c9c39e6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,9 @@
+dev:
+ @make tdd&
+ bun --hot run joe.ts
+
+test:
+ bun test
+
+tdd:
+ bun test --watch
\ No newline at end of file
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..b71b98b
Binary files /dev/null and b/bun.lockb differ
diff --git a/captain-update.mjs b/captain-update.mjs
deleted file mode 100755
index b432fc1..0000000
--- a/captain-update.mjs
+++ /dev/null
@@ -1,219 +0,0 @@
-#!/usr/bin/env zx
-$.verbose = argv.v || argv.verbose;
-
-if (argv.help) {
- console.log(`${chalk.white.bgBlue(
- "🐿️ captain-update.mjs - Check for updates of all deployed images on CapRover"
- )}
-
- Usage: ${chalk.yellow(
- "CAPTAIN_DOMAIN=captain.my.example.com CAPTAIN_PASSWORD='This is super S3cure!' ./captain-update.mjs [options]"
- )}
- ${chalk.grey(
- "CAPTAIN_DOMAIN must have the domain name of the CapRover instance"
- )}
- ${chalk.grey(
- "CAPTAIN_PASSWORD must have the password of the CapRover instance"
- )}
-
- Optional options:
- ${chalk.yellow("--help")} Display this help
- ${chalk.yellow("-v --verbose")} Verbose output
- `);
- process.exit(0);
-}
-
-const hasAllEnvVars =
- process.env.CAPTAIN_DOMAIN && process.env.CAPTAIN_PASSWORD;
-if (!hasAllEnvVars) {
- console.error(
- `Missing ${chalk.red("CAPTAIN_DOMAIN")} or ${chalk.red(
- "CAPTAIN_PASSWORD"
- )} environment variables. Use ${chalk.green("--help")} for help.`
- );
- process.exit(1);
-}
-
-const API_URL = `https://${process.env.CAPTAIN_DOMAIN}/api/v2`;
-
-const authResponse = await fetch(`${API_URL}/login`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "x-namespace": "captain",
- },
- body: JSON.stringify({
- password: process.env.CAPTAIN_PASSWORD,
- }),
-}).then((res) => res.json());
-
-const authToken = authResponse?.data?.token;
-if (!authToken) {
- console.error(`Failed to authenticate at ${API_URL}`);
- process.exit(1);
-}
-
-const fetchAuthenticatedApi = async (url, method = "GET", body = undefined) => {
- const response = await fetch(`${API_URL}${url}`, {
- method,
- headers: {
- "Content-Type": "application/json",
- "x-namespace": "captain",
- "x-captain-auth": authToken,
- },
- body: body ? JSON.stringify(body) : undefined,
- }).then((res) => res.json());
-
- return response;
-};
-
-const applicationsResponse = await fetchAuthenticatedApi(
- "/user/apps/appDefinitions"
-);
-
-console.log(
- chalk.green(
- `Found ${applicationsResponse.data.appDefinitions.length} applications on ${applicationsResponse.data.rootDomain}`
- )
-);
-const appStates = await Promise.all(
- applicationsResponse.data.appDefinitions.map(async (app) => {
- const currentVersion = app.versions[app.deployedVersion];
-
- const [imageName, currentTag] = currentVersion.deployedImageName.split(":");
- const imagePath = imageName.includes("/")
- ? imageName
- : `library/${imageName}`;
-
- let appState = {
- name: app.appName,
- lastDeployed: currentVersion.timeStamp,
- currentImage: {
- name: currentVersion.deployedImageName,
- dockerHubUrl: imageName.includes("/")
- ? `https://hub.docker.com/r/${imageName}`
- : `https://hub.docker.com/_/${imageName}`,
- image: imageName,
- tag: currentTag,
- },
- };
-
- const tagsResponse = await fetch(
- `https://hub.docker.com/v2/repositories/${imagePath}/tags?page_size=10&page=1&ordering=last_updated`
- )
- .then((res) => res.json())
- .catch((error) => {
- console.error(
- "Impossible to get Docker tags for image",
- chalk.red(imagePath)
- );
- return {};
- });
- const tags = tagsResponse.results;
-
- if (!tags || tags.length === 0) {
- return appState;
- }
-
- const latestTag = tags.find((tag) => tag.name === "latest");
- const latestTags = tags
- .filter((tag) => {
- if (!latestTag) {
- return true;
- }
- return tag.digest === latestTag.digest;
- })
- .map((tag) => tag.name);
-
- const isLatest = latestTags.includes(currentTag);
-
- appState.isLatest = latestTag && isLatest;
- appState.latestTags = latestTags;
- appState.recommendedVersion = `${imageName}:${latestTags.pop()}`;
-
- return appState;
- })
-);
-
-const appsUpToDate = appStates.filter((app) => app.isLatest);
-const appsUnknown = appStates.filter((app) => !app.recommendedVersion);
-const appsOutdated = appStates.filter(
- (app) => app.recommendedVersion && !app.isLatest
-);
-
-console.log(
- "\n",
- chalk.green(`# Applications report (${new Date().toISOString()})`),
- "\n\n"
-);
-
-console.log(chalk.green("## Applications up-to-date", "\n"));
-console.log(
- chalk.grey(
- "These applications seems up-to-date, congrats! Please check for unsupported software not updated recently, and improve this script if possible."
- ),
- "\n"
-);
-appsUpToDate
- .sort((a, b) => new Date(a.lastDeployed) - new Date(b.lastDeployed))
- .forEach((app) => {
- console.log(
- "- [x]",
- chalk.blue(app.name),
- `: ${app.currentImage.name}`,
- "\n\t",
- "- current:",
- app.currentImage.name,
- "\n\t",
- "- last deployed:",
- app.lastDeployed
- );
- });
-
-console.log("\n\n", chalk.green("## Applications unknown", "\n"));
-console.log(
- chalk.grey(
- "I couldn't automatically find the latest version for these apps. Please check manually, and improve this script if possible."
- ),
- "\n"
-);
-appsUnknown.forEach((app) => {
- console.log(
- "- [ ]",
- chalk.blue(app.name),
- `: ${app.currentImage.name} -> TODO`,
- "\n\t",
- "- current:",
- app.currentImage.name,
- "\n\t",
- "- docker hub URL:",
- app.currentImage.dockerHubUrl
- );
-});
-
-console.log("\n\n", chalk.green("## Applications outdated", "\n"));
-console.log(
- chalk.grey(
- "These applications seems outdated. Please try to update to their latest versions, and improve this script if possible."
- ),
- "\n"
-);
-appsOutdated.forEach((app) => {
- console.log(
- "- [ ]",
- chalk.blue(app.name),
- `: ${app.currentImage.name} -> ${app.recommendedVersion}`,
- "\n\t",
- "- current:",
- app.currentImage.name,
- "\n\t",
- "- recommended:",
- app.recommendedVersion,
- "\n\t",
- "- docker hub URL:",
- app.currentImage.dockerHubUrl,
- "\n\t",
- "- tags:",
- app.latestTags.join(", ")
- );
-});
diff --git a/joe.ts b/joe.ts
new file mode 100644
index 0000000..b4d4421
--- /dev/null
+++ b/joe.ts
@@ -0,0 +1,39 @@
+import Caprover from "./services/Caprover";
+import applications from "./routes/applications";
+import applicationsUpdate from "./routes/applications.update";
+import Layout, { html } from "./ui/Layout";
+import DockerHub from "./services/DockerHub";
+
+console.log("Hello Pierrot!");
+
+const caprover = new Caprover(
+ process.env.CAPTAIN_DOMAIN,
+ process.env.CAPTAIN_PASSWORD
+);
+
+const dockerHub = new DockerHub();
+
+const server = Bun.serve({
+ port: 3000,
+ fetch(req: Request) {
+ const url = new URL(req.url);
+ switch (url.pathname) {
+ case "/":
+ return new Response(
+ Layout(
+ html`
Hello World.
+ I'm Joe, your personal assistant.
`
+ ),
+ { headers: { "Content-Type": "text/html" } }
+ );
+ case "/applications":
+ return applications(req, caprover);
+ case "/applications/update":
+ return applicationsUpdate(req, caprover, dockerHub);
+ default:
+ return new Response("Not Found", { status: 404 });
+ }
+ },
+});
+
+console.log(`Server started at http://${server.hostname}:${server.port}`);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..59ae404
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+ "devDependencies": {
+ "bun-types": "^1.0.25"
+ }
+}
diff --git a/routes/applications.ts b/routes/applications.ts
new file mode 100644
index 0000000..d57af8a
--- /dev/null
+++ b/routes/applications.ts
@@ -0,0 +1,91 @@
+import Caprover, { Application } from "../services/Caprover";
+import Layout, { html } from "../ui/Layout";
+
+const OLD_PERIOD_IN_DAYS = 60;
+
+const ApplicationOverview = (application: Application) => {
+ const deployedAt = application.lastDeployedAt.toLocaleDateString("fr-FR");
+ return html`
+ ${application.name} |
+
+
+
+ ${application.imageName}
+
+ ${application.toString()}
+
+ |
+
+ ${application.isOlderThan(OLD_PERIOD_IN_DAYS)
+ ? `${deployedAt}`
+ : deployedAt}
+ |
+
+ ${application.dockerImage
+ ? `
+
+ `
+ : ""}
+ |
+
`;
+};
+
+type Sort = { field: string; order: "asc" | "desc" };
+
+const Page = (applications: Application[], currentSort: Sort) => {
+ const sortLink = (field: string, title: string) => {
+ let url = `?sort=${field}`;
+ let className = "";
+ if (currentSort.field === field) {
+ className = "current";
+ title += currentSort.order === "asc" ? " ▲" : " ▼";
+ url += `&order=${currentSort.order === "asc" ? "desc" : "asc"}`;
+ }
+
+ return `${title}`;
+ };
+
+ return html`
+
Applications
+
+
Update applications now!
+
+
+
+
+ ${sortLink("name", "Name")} |
+ Deployment |
+ ${sortLink("deployed", "Last deployed")} |
+ Actions |
+
+
+
+ ${applications.map((app) => ApplicationOverview(app)).join("")}
+
+
+
`;
+};
+
+export default async (req: Request, caprover: Caprover): Promise => {
+ const applications = await caprover.getApps();
+
+ const sort: Sort = {
+ field: new URL(req.url).searchParams.get("sort") ?? "name",
+ order:
+ new URL(req.url).searchParams.get("order") === "desc" ? "desc" : "asc",
+ };
+ if (sort.field === "name") {
+ applications.sort((a, b) => a.name.localeCompare(b.name));
+ } else if (sort.field === "deployed") {
+ applications.sort(
+ (a, b) => a.lastDeployedAt.getTime() - b.lastDeployedAt.getTime()
+ );
+ }
+ if (sort.order === "desc") {
+ applications.reverse();
+ }
+
+ return new Response(Layout(Page(applications, sort)), {
+ headers: { "Content-Type": "text/html" },
+ });
+};
diff --git a/routes/applications.update.ts b/routes/applications.update.ts
new file mode 100644
index 0000000..1367943
--- /dev/null
+++ b/routes/applications.update.ts
@@ -0,0 +1,82 @@
+import Caprover, { Application } from "../services/Caprover";
+import DockerHub from "../services/DockerHub";
+import Layout, { html } from "../ui/Layout";
+
+const UpdateForm = (application: Application, latestVersions: string[]) => {
+ // sort by number of "dots" in the version to have the most specific version first
+ latestVersions.sort((a, b) => {
+ return b.split(".").length - a.split(".").length;
+ });
+
+ return html``;
+};
+
+const Page = (application: Application, latestVersions: string[]) => {
+ return html`
+
Updating ${application.name}
+
+
+ - Last deployment
+ - ${application.lastDeployedAt.toLocaleString("fr-FR")}
+
+ - Current version
+ -
+ ${application.dockerImage
+ ? `
+ ${application.dockerImage.name}:${application.dockerImage.tag}
+ `
+ : `
${application.imageName}
… check yourself!`}
+
+
+ - Latest versions
+ -
+ ${latestVersions.length === 0
+ ? "No version found"
+ : UpdateForm(application, latestVersions)}
+
+
+
`;
+};
+
+export default async (
+ req: Request,
+ caprover: Caprover,
+ dockerHub: DockerHub
+): Promise => {
+ if (req.method === "POST") {
+ const body = await req.formData();
+ console.log("TODO Implement application update", [...body.entries()]);
+ throw new Error("Not implemented");
+ }
+
+ const applications = await caprover.getApps();
+
+ const appToUpdate = applications
+ .filter((app) => {
+ // can we help to update this app?
+ return app.dockerImage;
+ })
+ .find((app) => app.isOlderThan(30));
+
+ if (!appToUpdate) {
+ return new Response(Layout(html`No application to update 🎉
`), {
+ headers: { "Content-Type": "text/html" },
+ });
+ }
+
+ const latestVersions = await dockerHub.getLatestVersions(
+ appToUpdate.dockerImage!.name
+ );
+
+ return new Response(Layout(Page(appToUpdate, latestVersions)), {
+ headers: { "Content-Type": "text/html" },
+ });
+};
diff --git a/services/Caprover.test.ts b/services/Caprover.test.ts
new file mode 100644
index 0000000..28841da
--- /dev/null
+++ b/services/Caprover.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "bun:test";
+import { Application } from "./Caprover";
+
+describe("Caprover", () => {
+ describe("Application", () => {
+ const anAppDefinition = {
+ hasPersistentData: false,
+ description: "",
+ instanceCount: 1,
+ captainDefinitionRelativeFilePath: "./captain-definition",
+ networks: ["captain-overlay-network"],
+ envVars: [
+ {
+ key: "ADMINER_PLUGINS",
+ value: "",
+ },
+ {
+ key: "ADMINER_DESIGN",
+ value: "",
+ },
+ ],
+ volumes: [],
+ ports: [],
+ versions: [
+ {
+ version: 0,
+ timeStamp: "2020-08-02T01:25:07.232Z",
+ deployedImageName: "img-captain-adminer:0",
+ gitHash: "",
+ },
+ {
+ version: 1,
+ timeStamp: "2021-03-19T10:04:54.823Z",
+ deployedImageName: "adminer:4.8.0",
+ gitHash: "",
+ },
+ {
+ version: 2,
+ timeStamp: "2021-12-04T10:24:48.757Z",
+ deployedImageName: "adminer:4.8.1",
+ gitHash: "",
+ },
+ ],
+ deployedVersion: 2,
+ notExposeAsWebApp: false,
+ customDomain: [],
+ hasDefaultSubDomainSsl: true,
+ forceSsl: true,
+ websocketSupport: false,
+ containerHttpPort: 8080,
+ preDeployFunction: "",
+ serviceUpdateOverride: "",
+ appName: "adminer",
+ isAppBuilding: false,
+ };
+
+ it("should create an application from definition", () => {
+ const app = Application.createFromDefinition(anAppDefinition);
+ expect(app.name).toBe("adminer");
+ });
+
+ describe("docker image", () => {
+ it("should return the docker image name of the current version", () => {
+ const app = Application.createFromDefinition(anAppDefinition);
+ expect(app.dockerImage).toEqual({
+ name: "adminer",
+ tag: "4.8.1",
+ hubUrl: "https://hub.docker.com/_/adminer",
+ });
+ });
+
+ it("should parse names with organization namespace", () => {
+ const app = Application.createFromDefinition({
+ ...anAppDefinition,
+ deployedVersion: 0,
+ versions: [
+ {
+ ...anAppDefinition.versions[0],
+ deployedImageName: "vaultwarden/server:1.30.0-alpine",
+ },
+ ],
+ });
+ expect(app.dockerImage).toEqual({
+ name: "vaultwarden/server",
+ tag: "1.30.0-alpine",
+ hubUrl: "https://hub.docker.com/r/vaultwarden/server",
+ });
+ });
+
+ it("should not parse custom built images", () => {
+ const app = Application.createFromDefinition({
+ ...anAppDefinition,
+ deployedVersion: 0,
+ versions: [
+ {
+ ...anAppDefinition.versions[0],
+ deployedImageName:
+ "registry.example.org:996/captain/img-captain-nextcloud-cron:4",
+ },
+ ],
+ });
+ expect(app.dockerImage).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/services/Caprover.ts b/services/Caprover.ts
new file mode 100644
index 0000000..e8bafd0
--- /dev/null
+++ b/services/Caprover.ts
@@ -0,0 +1,115 @@
+type TODO_TypeDefinition = any;
+
+class Application {
+ private constructor(private readonly data: any) {}
+
+ static createFromDefinition(data: any): Application {
+ return new Application(data);
+ }
+
+ get name(): string {
+ return this.data.appName;
+ }
+ get lastDeployedAt(): Date {
+ return new Date(this.currentVersion.timeStamp);
+ }
+ get imageName(): string {
+ return this.currentVersion.deployedImageName;
+ }
+ get dockerImage(): undefined | { name: string; tag: string; hubUrl: string } {
+ const match = this.imageName.match(/^(.*):(.*)$/);
+ if (!match || match[1].includes("/captain/")) {
+ return undefined;
+ }
+
+ const name = match[1];
+ return {
+ name: name,
+ tag: match[2],
+ hubUrl: name.includes("/")
+ ? `https://hub.docker.com/r/${name}`
+ : `https://hub.docker.com/_/${name}`,
+ };
+ }
+
+ isOlderThan(days: number): boolean {
+ const daysInMs = days * 24 * 60 * 60 * 1000;
+ const now = Date.now();
+ return now - this.lastDeployedAt.getTime() > daysInMs;
+ }
+
+ toString(): string {
+ return JSON.stringify(this.data, null, 2);
+ }
+
+ private get currentVersion(): TODO_TypeDefinition {
+ return this.data.versions.find((version: { version: number }) => {
+ return version.version === this.data.deployedVersion;
+ });
+ }
+}
+
+class Caprover {
+ private authToken: string = "";
+ private readonly apiUrl: string;
+
+ constructor(readonly domain?: string, private readonly password?: string) {
+ if (!domain || !password) {
+ throw new Error("Missing domain or password");
+ }
+ this.apiUrl = `https://${domain}/api/v2`;
+ }
+
+ async getApps(): Promise {
+ const res = await this.fetch("/user/apps/appDefinitions").then((res) =>
+ res.json()
+ );
+
+ return (
+ res.data?.appDefinitions?.map(Application.createFromDefinition) ?? []
+ );
+ }
+
+ private async authenticate() {
+ if (this.authToken) {
+ return;
+ }
+
+ console.debug("Trying to authenticate at", this.apiUrl);
+
+ const authResponse = await fetch(`${this.apiUrl}/login`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-namespace": "captain",
+ },
+ body: JSON.stringify({
+ password: this.password,
+ }),
+ }).then((res) => res.json());
+
+ this.authToken = authResponse?.data?.token;
+ if (!this.authToken) {
+ throw new Error(`Failed to authenticate at ${this.apiUrl}`);
+ }
+ console.debug("Authenticated successfully at", this.apiUrl, this.authToken);
+ }
+
+ private async fetch(path: string, options?: RequestInit) {
+ await this.authenticate();
+
+ console.debug("-> Caprover Fetching", path);
+ return fetch(`${this.apiUrl}${path}`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...(options?.headers || {}),
+ "x-namespace": "captain",
+ "x-captain-auth": this.authToken,
+ },
+ });
+ }
+}
+
+export { Application };
+export default Caprover;
diff --git a/services/DockerHub.ts b/services/DockerHub.ts
new file mode 100644
index 0000000..36b229e
--- /dev/null
+++ b/services/DockerHub.ts
@@ -0,0 +1,39 @@
+type Tag = {
+ name: string;
+ digest: string;
+};
+
+class DockerHub {
+ getLatestVersions = async (imageName: string): Promise => {
+ const imagePath = imageName.includes("/")
+ ? imageName
+ : `library/${imageName}`;
+
+ const tagsResponse = await this.fetch(
+ `/${imagePath}/tags?page_size=10&ordering=last_updated`
+ ).then((res) => res.json());
+
+ const tags: Tag[] = tagsResponse.results;
+ if (tags.length === 0) {
+ throw new Error(`No tags found for ${imageName}`);
+ }
+
+ const latestTag = tags.find((tag) => tag.name === "latest");
+ if (!latestTag) {
+ return [];
+ }
+
+ const latestTags = tags
+ .filter((tag) => tag.digest === latestTag.digest)
+ .map((tag) => tag.name);
+ return latestTags;
+ };
+
+ private fetch = async (url: string): Promise => {
+ const fullUrl = `https://hub.docker.com/v2/repositories${url}`;
+ console.debug("-> Docker: Fetching", fullUrl);
+ return fetch(fullUrl);
+ };
+}
+
+export default DockerHub;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..5b26ed3
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "target": "es2017",
+ "module": "commonjs",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "types": ["bun-types"]
+ }
+}
\ No newline at end of file
diff --git a/ui/Layout.ts b/ui/Layout.ts
new file mode 100644
index 0000000..6fdb48e
--- /dev/null
+++ b/ui/Layout.ts
@@ -0,0 +1,33 @@
+export const html = String.raw;
+
+const Layout = (content: string) => {
+ return html`
+
+ Joe
+
+
+
+
+
+ ${content}
+
+
+
+ `;
+};
+
+export default Layout;