mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release: - W1-KNIP — exports/types tiers flipped to enforcing-mode on main; 35 unused exports + 52 unused types triaged to 0/0; shadcn surface + zod-infer contract types scoped via knip.json ignore-block. - W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper + `/api/dashboard/widgets` 422 now returns every Zod issue + worker audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43. - W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files migrated to the queryKeys factory; 9 new factory entries; ESLint rule + test-guard scope extended in lockstep. - W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine sites (seven status helpers + bucket-series + bp-in-target); Suspense double-comment consolidated; doctor-report sanitiser regex rewritten to escape-form so diffs are reviewable; pr-detection- worker soft-delete filter; offhost-backup DR-intent comment. - W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard tile-strip placeholder parity with the live TrendCard chrome. - W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier (transient/reauth/persistent) + WithingsApiError typed throw + the `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2 silent-disable gap the wave was conceived to close. No migration, no env-var change, no API contract break for iOS v0.5.4. The new `/api/dashboard/widgets` 422 envelope is additively extended. pnpm test --run green at 4815 passing / 1 skipped. pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
This commit is contained in:
1 parent
67207d725e
commit
9b6e237f5f
121 files changed
+6481
-590
No files matched your search
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* v1.4.42 W6 — env-check sanity tests.
|
||||
*
|
||||
* The script's two pure helpers (`parseEnvFile` + `checkEnv`) are
|
||||
* pinned here. The renderResults / main() entrypoint is intentionally
|
||||
* not unit-tested — it's a thin shell over the pure helpers and the
|
||||
* exit-code behaviour is best left to a smoke test in CI (deferred to
|
||||
* v1.4.43).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkEnv, parseEnvFile } from "../check-env";
|
||||
|
||||
describe("parseEnvFile", () => {
|
||||
it("parses simple KEY=VALUE pairs", () => {
|
||||
expect(parseEnvFile("FOO=bar\nBAZ=qux")).toEqual({
|
||||
FOO: "bar",
|
||||
BAZ: "qux",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips surrounding double quotes", () => {
|
||||
expect(parseEnvFile('FOO="bar baz"')).toEqual({ FOO: "bar baz" });
|
||||
});
|
||||
|
||||
it("strips surrounding single quotes", () => {
|
||||
expect(parseEnvFile("FOO='bar baz'")).toEqual({ FOO: "bar baz" });
|
||||
});
|
||||
|
||||
it("skips comments and blank lines", () => {
|
||||
const src = `# header comment
|
||||
FOO=bar
|
||||
|
||||
# another
|
||||
BAZ=qux
|
||||
`;
|
||||
expect(parseEnvFile(src)).toEqual({ FOO: "bar", BAZ: "qux" });
|
||||
});
|
||||
|
||||
it("preserves an empty value (the v1.4.40 .p8 gap pattern)", () => {
|
||||
// An empty string MUST round-trip as "" so the presence-check
|
||||
// downstream classifies it as missing.
|
||||
expect(parseEnvFile("APNS_KEY=")).toEqual({ APNS_KEY: "" });
|
||||
});
|
||||
|
||||
it("handles CRLF line endings (Coolify exports use \\r\\n)", () => {
|
||||
expect(parseEnvFile("FOO=bar\r\nBAZ=qux")).toEqual({
|
||||
FOO: "bar",
|
||||
BAZ: "qux",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const SAMPLE_MANIFEST = {
|
||||
description: "test manifest",
|
||||
groups: [
|
||||
{
|
||||
name: "Core",
|
||||
description: "required boot vars",
|
||||
required: true,
|
||||
variables: [
|
||||
{ name: "DATABASE_URL", purpose: "db" },
|
||||
{ name: "ENCRYPTION_KEY", purpose: "crypto" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "APNs",
|
||||
description: "iOS push",
|
||||
required: false,
|
||||
allOrNone: true,
|
||||
variables: [
|
||||
{ name: "APNS_KEY_ID", purpose: "key id" },
|
||||
{ name: "APNS_TEAM_ID", purpose: "team id" },
|
||||
{
|
||||
name: "APNS_KEY",
|
||||
purpose: "key body or file",
|
||||
anyOf: ["APNS_KEY", "APNS_KEY_FILE"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Backups",
|
||||
description: "all-or-none",
|
||||
required: false,
|
||||
allOrNone: true,
|
||||
variables: [
|
||||
{ name: "BACKUP_S3_BUCKET", purpose: "bucket" },
|
||||
{ name: "BACKUP_S3_ACCESS_KEY", purpose: "key" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("checkEnv — required group", () => {
|
||||
it("flags every missing required var", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {});
|
||||
const required = out.filter((r) => r.required && !r.present);
|
||||
expect(required.map((r) => r.variable)).toEqual([
|
||||
"DATABASE_URL",
|
||||
"ENCRYPTION_KEY",
|
||||
]);
|
||||
});
|
||||
|
||||
it("classifies whitespace-only values as missing (deploy-failure prevention)", () => {
|
||||
// A `DATABASE_URL=" "` would silently boot with a useless value;
|
||||
// the deployer expects to see a clear MISSING-REQUIRED row here.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: " ",
|
||||
ENCRYPTION_KEY: "ok",
|
||||
});
|
||||
const row = out.find((r) => r.variable === "DATABASE_URL")!;
|
||||
expect(row.present).toBe(false);
|
||||
expect(row.required).toBe(true);
|
||||
});
|
||||
|
||||
it("greenlights when every required var is non-empty", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "postgresql://x",
|
||||
ENCRYPTION_KEY: "deadbeef".repeat(8),
|
||||
});
|
||||
const required = out.filter((r) => r.required);
|
||||
expect(required.every((r) => r.present)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkEnv — anyOf alternatives", () => {
|
||||
it("treats APNS_KEY as satisfied when APNS_KEY_FILE is set instead", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY_FILE: "/secrets/apns.p8",
|
||||
});
|
||||
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
||||
expect(apnsKey.present).toBe(true);
|
||||
});
|
||||
|
||||
it("records satisfiedBy when an anyOf alternative satisfies the row", () => {
|
||||
// The label `[OK] APNS_KEY` was misleading pre-v1.4.42 — an operator
|
||||
// who saw it would grep for `APNS_KEY` in the env block and find
|
||||
// nothing, file a ghost issue. `satisfiedBy` lets the renderer
|
||||
// surface the alternative that actually matched.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY_FILE: "/secrets/apns.p8",
|
||||
});
|
||||
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
||||
expect(apnsKey.satisfiedBy).toBe("APNS_KEY_FILE");
|
||||
});
|
||||
|
||||
it("leaves satisfiedBy undefined when the primary name is the one set", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY: "-----BEGIN PRIVATE KEY-----...",
|
||||
});
|
||||
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
||||
expect(apnsKey.satisfiedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats APNS_KEY as satisfied when APNS_KEY is set (the 12-factor variant)", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY: "-----BEGIN PRIVATE KEY-----...",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "APNS_KEY")?.present).toBe(true);
|
||||
});
|
||||
|
||||
it("marks APNS_KEY missing when BOTH alternatives are absent (the v1.4.40 AP-2 gap)", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "APNS_KEY")?.present).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkEnv — all-or-none groups", () => {
|
||||
it("emits a synthetic <all-or-none> required row when a group is half-populated", () => {
|
||||
// The v1.4.40 AP-2 silent-disable pattern: 3 of 4 APNS_* set, .p8
|
||||
// missing → app silently falls back to Telegram. The all-or-none
|
||||
// synthetic row catches this even when the surrounding group is
|
||||
// `required: false`.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
BACKUP_S3_BUCKET: "my-bucket",
|
||||
// BACKUP_S3_ACCESS_KEY deliberately absent
|
||||
});
|
||||
const synth = out.find((r) => r.variable === "<all-or-none>")!;
|
||||
expect(synth).toBeDefined();
|
||||
expect(synth.required).toBe(true);
|
||||
expect(synth.note).toContain("1/2");
|
||||
});
|
||||
|
||||
it("does NOT emit the synthetic row when the group is fully set", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
BACKUP_S3_BUCKET: "my-bucket",
|
||||
BACKUP_S3_ACCESS_KEY: "key",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "<all-or-none>")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT emit the synthetic row when the group is fully empty", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "<all-or-none>")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("catches the v1.4.40 AP-2 scenario — 3 of 4 APNS_* set, key missing", () => {
|
||||
// Direct regression pin for the gap the env-check wave was conceived
|
||||
// to close. Pre-v1.4.42 the APNs group lacked `allOrNone: true` so
|
||||
// this exact env-block shape exited 0 and silently disabled APNs.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
// APNS_KEY / APNS_KEY_FILE deliberately absent
|
||||
});
|
||||
const synth = out.find(
|
||||
(r) => r.group === "APNs" && r.variable === "<all-or-none>",
|
||||
)!;
|
||||
expect(synth).toBeDefined();
|
||||
expect(synth.required).toBe(true);
|
||||
expect(synth.note).toContain("2/3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkEnv — optional groups (informational)", () => {
|
||||
it("reports optional vars without setting required: true", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
});
|
||||
const apnsKeyId = out.find((r) => r.variable === "APNS_KEY_ID")!;
|
||||
expect(apnsKeyId.present).toBe(false);
|
||||
expect(apnsKeyId.required).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Pre-deploy env-var sanity check.
|
||||
*
|
||||
* Reads `scripts/env-manifest.json` and verifies every variable
|
||||
* declared `required: true` (group-level) is present + non-empty in
|
||||
* the runtime environment. Optional groups are reported as
|
||||
* informational so an operator can spot misconfigurations (e.g. three
|
||||
* out of four APNS_* vars set — the silent-disable case that bit us
|
||||
* for 3 days in v1.4.40).
|
||||
*
|
||||
* Designed to run in two modes:
|
||||
*
|
||||
* 1. Live env (default): inspects `process.env` after dotenv has
|
||||
* loaded `.env` / `.env.production`. This is what `pnpm
|
||||
* check-env` does locally and what CI will run against the
|
||||
* pre-deploy environment.
|
||||
*
|
||||
* 2. File mode: `pnpm check-env --file .env.production` reads a
|
||||
* KEY=VALUE file directly without loading it into `process.env`.
|
||||
* Useful for checking a Coolify export against the manifest
|
||||
* without polluting the local shell.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all required variables present + non-empty.
|
||||
* 1 — at least one required variable missing/empty.
|
||||
* 2 — manifest file unreadable or malformed.
|
||||
*
|
||||
* Future (v1.4.43): CI integration via a GitHub Action that runs this
|
||||
* against a `.env.production.example`-style file in the repo,
|
||||
* blocking PRs that introduce new required vars without updating the
|
||||
* manifest.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { argv, exit, env as processEnv } from "node:process";
|
||||
|
||||
const MANIFEST_PATH = resolve(__dirname, "env-manifest.json");
|
||||
|
||||
interface ManifestVariable {
|
||||
name: string;
|
||||
purpose: string;
|
||||
example?: string;
|
||||
/**
|
||||
* Alternative names — if at least one is present, the variable is
|
||||
* considered satisfied. Used for APNS_KEY vs APNS_KEY_FILE.
|
||||
*/
|
||||
anyOf?: string[];
|
||||
}
|
||||
|
||||
interface ManifestGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
allOrNone?: boolean;
|
||||
variables: ManifestVariable[];
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
description: string;
|
||||
groups: ManifestGroup[];
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
group: string;
|
||||
variable: string;
|
||||
present: boolean;
|
||||
required: boolean;
|
||||
note?: string;
|
||||
/**
|
||||
* For `anyOf` rows: the alternative that actually satisfied the row.
|
||||
* Set only when the matched name differs from the primary `variable`
|
||||
* — e.g. APNS_KEY satisfied by APNS_KEY_FILE. Lets the renderer print
|
||||
* `[OK] APNS_KEY (satisfied by APNS_KEY_FILE)` instead of misleading
|
||||
* the operator into greping for the primary name.
|
||||
*/
|
||||
satisfiedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a KEY=VALUE file into an env-lookup object. Mirrors dotenv's
|
||||
* minimal behaviour (no variable expansion, no escape sequences) so a
|
||||
* Coolify export file or .env.production can be inspected without
|
||||
* pulling in the full dotenv parse path.
|
||||
*
|
||||
* - Lines beginning with `#` are comments.
|
||||
* - Empty lines are skipped.
|
||||
* - Quoted values (`"..."` or `'...'`) have the quotes stripped.
|
||||
* - Whitespace around `=` is preserved (Coolify never adds any).
|
||||
*/
|
||||
export function parseEnvFile(content: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
// Strip surrounding quotes — dotenv accepts both flavours.
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable counts as "present" only when the value is defined AND
|
||||
* non-empty (whitespace-only counts as empty). Pre-v1.4.42 the v1.4.40
|
||||
* AP-2 gap shipped because the .p8 file simply did not exist — but a
|
||||
* `""` value would have passed an `existsSync`-style check too.
|
||||
*/
|
||||
function isPresent(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure classifier — exported so unit tests can drive it without
|
||||
* hitting the filesystem. Given a manifest and an env lookup, return
|
||||
* one result row per declared variable plus any group-level
|
||||
* all-or-none violations.
|
||||
*/
|
||||
export function checkEnv(
|
||||
manifest: Manifest,
|
||||
env: Record<string, string | undefined>,
|
||||
): CheckResult[] {
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
for (const group of manifest.groups) {
|
||||
let presentInGroup = 0;
|
||||
let absentInGroup = 0;
|
||||
|
||||
for (const v of group.variables) {
|
||||
const names = v.anyOf ?? [v.name];
|
||||
const matched = names.find((n) => isPresent(env[n]));
|
||||
const present = matched !== undefined;
|
||||
const note = v.anyOf
|
||||
? `Satisfied by any of: ${v.anyOf.join(", ")}`
|
||||
: undefined;
|
||||
results.push({
|
||||
group: group.name,
|
||||
variable: v.name,
|
||||
present,
|
||||
required: group.required,
|
||||
note,
|
||||
satisfiedBy:
|
||||
present && matched && matched !== v.name ? matched : undefined,
|
||||
});
|
||||
if (present) presentInGroup++;
|
||||
else absentInGroup++;
|
||||
}
|
||||
|
||||
// All-or-none groups: surface a synthetic row when the group is
|
||||
// partially populated. This catches the v1.4.40 AP-2 pattern even
|
||||
// when the group itself is `required: false`.
|
||||
if (
|
||||
group.allOrNone &&
|
||||
presentInGroup > 0 &&
|
||||
absentInGroup > 0
|
||||
) {
|
||||
results.push({
|
||||
group: group.name,
|
||||
variable: "<all-or-none>",
|
||||
present: false,
|
||||
// Promote to required severity — a partial all-or-none group
|
||||
// is always wrong, no matter the surrounding group flag.
|
||||
required: true,
|
||||
note: `Group is all-or-none but only ${presentInGroup}/${
|
||||
presentInGroup + absentInGroup
|
||||
} variables are set. Either set all or unset all.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a one-row-per-variable summary to stdout. The output is
|
||||
* intentionally grep-friendly so a CI pipeline can pattern-match for
|
||||
* `[MISSING-REQUIRED]` to fail the build.
|
||||
*/
|
||||
function renderResults(results: CheckResult[]): {
|
||||
missingRequired: number;
|
||||
missingOptional: number;
|
||||
} {
|
||||
let missingRequired = 0;
|
||||
let missingOptional = 0;
|
||||
let currentGroup = "";
|
||||
|
||||
for (const r of results) {
|
||||
if (r.group !== currentGroup) {
|
||||
console.log(`\n# ${r.group}`);
|
||||
currentGroup = r.group;
|
||||
}
|
||||
if (r.present) {
|
||||
console.log(
|
||||
` [OK] ${r.variable}` +
|
||||
(r.satisfiedBy ? ` (satisfied by ${r.satisfiedBy})` : ""),
|
||||
);
|
||||
} else if (r.required) {
|
||||
console.log(
|
||||
` [MISSING-REQUIRED] ${r.variable}` +
|
||||
(r.note ? ` — ${r.note}` : ""),
|
||||
);
|
||||
missingRequired++;
|
||||
} else {
|
||||
console.log(
|
||||
` [missing-optional] ${r.variable}` + (r.note ? ` — ${r.note}` : ""),
|
||||
);
|
||||
missingOptional++;
|
||||
}
|
||||
}
|
||||
|
||||
return { missingRequired, missingOptional };
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point — kept as a named export so a test harness can call it
|
||||
* with a synthetic argv without spawning a child process.
|
||||
*/
|
||||
export function main(args: string[]): number {
|
||||
const fileFlagIdx = args.indexOf("--file");
|
||||
let env: Record<string, string | undefined>;
|
||||
let envLabel: string;
|
||||
|
||||
if (fileFlagIdx !== -1 && args[fileFlagIdx + 1]) {
|
||||
const filePath = resolve(args[fileFlagIdx + 1]!);
|
||||
try {
|
||||
env = parseEnvFile(readFileSync(filePath, "utf8"));
|
||||
envLabel = filePath;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read env file at ${filePath}: ${err}`);
|
||||
return 2;
|
||||
}
|
||||
} else {
|
||||
env = processEnv;
|
||||
envLabel = "process.env";
|
||||
}
|
||||
|
||||
let manifest: Manifest;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as Manifest;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read manifest at ${MANIFEST_PATH}: ${err}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
console.log(`Checking ${envLabel} against ${MANIFEST_PATH}`);
|
||||
const results = checkEnv(manifest, env);
|
||||
const { missingRequired, missingOptional } = renderResults(results);
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
`Summary: ${results.filter((r) => r.present).length}/${results.length} variables present`,
|
||||
);
|
||||
if (missingRequired > 0) {
|
||||
console.log(
|
||||
` ${missingRequired} REQUIRED variable(s) missing — deploy WILL FAIL.`,
|
||||
);
|
||||
}
|
||||
if (missingOptional > 0) {
|
||||
console.log(
|
||||
` ${missingOptional} optional variable(s) missing — feature will silently disable.`,
|
||||
);
|
||||
}
|
||||
|
||||
return missingRequired > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// Only run when invoked directly (`pnpm check-env` / `tsx scripts/check-env.ts`).
|
||||
// `require.main === module` survives the bundler-style ESM->CJS shim
|
||||
// that tsx uses.
|
||||
if (require.main === module) {
|
||||
exit(main(argv.slice(2)));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "./env-manifest.schema.json",
|
||||
"description": "Manifest of env vars required by a HealthLog production deployment. Used by `pnpm check-env` to catch missing/empty vars BEFORE a deploy ships. v1.4.42 W6 — born out of the v1.4.40 AP-2 .p8 install gap that shipped silently for 3 days.",
|
||||
"groups": [
|
||||
{
|
||||
"name": "Core",
|
||||
"description": "Database, app URL, encryption keys. The app refuses to boot without these.",
|
||||
"required": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DATABASE_URL",
|
||||
"purpose": "Postgres connection string. App boot fails if missing.",
|
||||
"example": "postgresql://healthlog:<pw>@db:5432/healthlog?schema=public"
|
||||
},
|
||||
{
|
||||
"name": "POSTGRES_PASSWORD",
|
||||
"purpose": "Database password for the bundled `db` service in docker-compose.yml.",
|
||||
"example": "<openssl rand -base64 24>"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_APP_URL",
|
||||
"purpose": "Public URL embedded in OAuth redirects, webhook callbacks, APNs deep-links."
|
||||
},
|
||||
{
|
||||
"name": "APP_URL",
|
||||
"purpose": "Server-side mirror of NEXT_PUBLIC_APP_URL; used by background workers."
|
||||
},
|
||||
{
|
||||
"name": "ENCRYPTION_KEY",
|
||||
"purpose": "AES-256-GCM key for at-rest encryption of provider tokens. 64 hex chars (32 bytes). Required unless ENCRYPTION_KEYS is set."
|
||||
},
|
||||
{
|
||||
"name": "API_TOKEN_HMAC_KEY",
|
||||
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens. 64 hex chars."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Withings OAuth",
|
||||
"description": "Required to enable the Withings integration. App boots without these but Withings sync stays disabled.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_ID",
|
||||
"purpose": "Withings OAuth client_id from https://developer.withings.com."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_SECRET",
|
||||
"purpose": "Withings OAuth client_secret."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_WEBHOOK_SECRET",
|
||||
"purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "APNs (iOS push)",
|
||||
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "APNS_KEY_ID",
|
||||
"purpose": "10-char Key ID from Apple Developer."
|
||||
},
|
||||
{
|
||||
"name": "APNS_TEAM_ID",
|
||||
"purpose": "10-char Team ID from Apple Developer."
|
||||
},
|
||||
{
|
||||
"name": "APNS_BUNDLE_ID",
|
||||
"purpose": "iOS app bundle id, e.g. dev.healthlog.ios."
|
||||
},
|
||||
{
|
||||
"name": "APNS_KEY",
|
||||
"purpose": "APNs auth key (.p8 contents). Use APNS_KEY for 12-factor env-block deploys; APNS_KEY_FILE is the filesystem alternative — at least one of the two must be set.",
|
||||
"anyOf": ["APNS_KEY", "APNS_KEY_FILE"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Deploy webhook",
|
||||
"description": "Coolify -> HealthLog deploy-result webhook. Without this, the auto-deploy success/failure feedback loop is broken.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DEPLOY_WEBHOOK_SECRET",
|
||||
"purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Off-host backups",
|
||||
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{ "name": "BACKUP_S3_ENDPOINT", "purpose": "S3-compatible endpoint URL." },
|
||||
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
|
||||
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
|
||||
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
|
||||
{ "name": "BACKUP_ENCRYPTION_KEY", "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user