Files
hobbs-iac/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/container/start.mjs
T
jeremy e9c7e9df1a feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 18:27:22 -06:00

107 lines
3.4 KiB
JavaScript

import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { spawn } from "node:child_process";
import process from "node:process";
async function readable(path) {
try {
await access(path, constants.R_OK);
return true;
} catch {
return false;
}
}
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile, required = false) {
const direct = process.env[environmentName];
if (direct !== undefined) return direct.trim();
const configuredFile = process.env[fileEnvironmentName] || defaultFile;
if (await readable(configuredFile)) return (await readFile(configuredFile, "utf8")).trim();
if (required) throw new Error(`${environmentName} is required; create ${configuredFile}`);
return "";
}
function validateCalendarConfiguration(raw) {
let values;
try {
values = JSON.parse(raw);
} catch {
throw new Error("Calendar secret must be a JSON array");
}
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
throw new Error("Calendar secret must contain between 1 and 10 URLs");
}
const normalized = values.map((value) => {
const url = new URL(String(value));
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
throw new Error("Every calendar URL must be an HTTPS calendar.google.com basic.ics URL");
}
return url.toString();
});
return JSON.stringify([...new Set(normalized)]);
}
async function main() {
const calendarSecret = await settingFromFile(
"CALENDAR_ICAL_URLS",
"CALENDAR_ICAL_URLS_FILE",
"/run/secrets/calendar_ical_urls",
true,
);
const accessToken = await settingFromFile(
"ORGANIZER_ACCESS_TOKEN",
"ORGANIZER_ACCESS_TOKEN_FILE",
"/run/secrets/organizer_access_token",
);
if (accessToken && !/^[A-Za-z0-9._~-]{32,512}$/.test(accessToken)) {
throw new Error("ORGANIZER_ACCESS_TOKEN must be empty or 32 to 512 URL-safe characters");
}
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
try {
new Intl.DateTimeFormat("en-US", { timeZone: organizerTimeZone }).format(new Date());
} catch {
throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`);
}
const environment = {
...process.env,
CALENDAR_ICAL_URLS: validateCalendarConfiguration(calendarSecret),
ORGANIZER_ACCESS_TOKEN: accessToken,
ORGANIZER_TIMEZONE: organizerTimeZone,
HOSTNAME: process.env.HOSTNAME || "0.0.0.0",
PORT: process.env.PORT || "3000",
};
const childOptions = {
cwd: process.cwd(),
env: environment,
stdio: "inherit",
};
if (process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() === 0) {
childOptions.uid = 10001;
childOptions.gid = 10001;
}
const child = spawn(
process.execPath,
["node_modules/next/dist/bin/next", "start", "-H", environment.HOSTNAME, "-p", environment.PORT],
childOptions,
);
const forwardSignal = (signal) => {
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 1);
});
}
main().catch((error) => {
console.error(`[organizer] startup failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});