clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix/175-template-quality-pass
lofi / package / tooling / provision.ts
provision.ts 7.9K
1/**2 * Bootstrap a managed Jazz app for a lofi project.3 *4 * Local-first projects run with no configuration at all. To turn on managed sync5 * — so a user can elect to back up and roam their account — a project needs a6 * Jazz app id and the managed sync server. Jazz's dashboard exposes a public7 * endpoint that mints a fresh app and hands back its secrets; the app is8 * unclaimed and expires unless you claim it from the dashboard within the9 * grace window.10 *11 * This module is the small, honest primitive behind `deno task jazz:provision`12 * and `create --sync`: it calls that endpoint, maps the response onto lofi's13 * four environment names, and merges them into a `.env` file without disturbing14 * anything else there. Secrets are written to the (git-ignored) file, never15 * echoed to stdout.16 *17 * @module18 */1920import { environmentNames, parseDotenv } from "./environment.ts";2122/** Jazz's public app-generation endpoint. Returns a fresh, unclaimed app. */23export const JAZZ_GENERATE_ENDPOINT = "https://v2.dashboard.jazz.tools/api/apps/generate";24/** The managed Jazz Cloud sync server a generated app syncs against. */25export const JAZZ_MANAGED_SERVER_URL = "https://v2.sync.jazz.tools/";26/** Where a generated app is claimed before it expires. */27export const JAZZ_DASHBOARD_URL = "https://v2.dashboard.jazz.tools/";28/** How long an unclaimed app survives — stated to the user, not enforced here. */29export const JAZZ_CLAIM_WINDOW_DAYS = 14;3031/** The secrets a freshly generated Jazz app is provisioned with. */32export type GeneratedJazzApp = {33 /** Public app id (client-visible). Maps to `JAZZ_APP_ID`. */34 appId: string;35 /** Admin secret for schema/permission deploys. Maps to `JAZZ_ADMIN_SECRET`. */36 adminSecret: string;37 /** Backend secret for server-side use. Maps to `BACKEND_SECRET`. */38 backendSecret: string;39};4041/** A precise, non-leaking failure reason for provisioning. */42export class ProvisionError extends Error {43 override readonly name = "ProvisionError";44 constructor(message: string) {45 super(message);46 }47}4849function isNonEmptyString(value: unknown): value is string {50 return typeof value === "string" && value.trim().length > 0;51}5253/**54 * Calls the Jazz app-generation endpoint and returns the new app's secrets.55 * `fetchImpl` is injectable so the flow is testable without a network.56 */57export async function generateJazzApp(58 fetchImpl: typeof fetch = fetch,59): Promise<GeneratedJazzApp> {60 let response: Response;61 try {62 response = await fetchImpl(JAZZ_GENERATE_ENDPOINT, {63 method: "POST",64 headers: { "content-type": "application/json" },65 body: "{}",66 });67 } catch (cause) {68 throw new ProvisionError(69 `could not reach the Jazz dashboard to generate an app (${70 cause instanceof Error ? cause.message : String(cause)71 }); check your connection and retry`,72 );73 }74 if (!response.ok) {75 throw new ProvisionError(76 `the Jazz dashboard rejected the app-generation request (HTTP ${response.status}); retry later`,77 );78 }79 let payload: unknown;80 try {81 payload = await response.json();82 } catch {83 throw new ProvisionError("the Jazz dashboard returned a response that was not valid JSON");84 }85 const record = (payload ?? {}) as Record<string, unknown>;86 if (87 !isNonEmptyString(record.appId) ||88 !isNonEmptyString(record.adminSecret) ||89 !isNonEmptyString(record.backendSecret)90 ) {91 throw new ProvisionError(92 "the Jazz dashboard response was missing an appId, adminSecret, or backendSecret",93 );94 }95 return {96 appId: record.appId.trim(),97 adminSecret: record.adminSecret.trim(),98 backendSecret: record.backendSecret.trim(),99 };100}101102/** The four environment values a generated app populates. */103export function environmentForApp(104 app: GeneratedJazzApp,105 serverUrl: string = JAZZ_MANAGED_SERVER_URL,106): Record<string, string> {107 return {108 JAZZ_APP_ID: app.appId,109 JAZZ_SERVER_URL: serverUrl,110 JAZZ_ADMIN_SECRET: app.adminSecret,111 BACKEND_SECRET: app.backendSecret,112 };113}114115/**116 * Merges `values` into the text of a `.env` file, updating existing assignments117 * in place and appending any that are absent. Every duplicate assignment of a118 * key is rewritten — dotenv parsing treats the last occurrence as119 * authoritative, so leaving a later duplicate stale would make provisioning120 * report success while commands keep reading the old value. Every other line —121 * comments, blank lines, unrelated keys — is preserved verbatim.122 */123export function mergeEnv(existing: string, values: Record<string, string>): string {124 const replacements = new Map(Object.entries(values));125 const written = new Set<string>();126 const lines = existing.length === 0 ? [] : existing.split("\n");127 const updated = lines.map((line) => {128 const match = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=)/.exec(line);129 if (!match) return line;130 const name = match[2];131 if (!replacements.has(name)) return line;132 written.add(name);133 return `${match[1]}${name}=${replacements.get(name)!}`;134 });135 const missing = [...replacements].filter(([name]) => !written.has(name));136 if (missing.length > 0) {137 if (updated.length > 0 && updated[updated.length - 1].trim() !== "") updated.push("");138 for (const [name, value] of missing) updated.push(`${name}=${value}`);139 }140 return `${updated.join("\n").replace(/\n+$/, "")}\n`;141}142143/** Whether a parsed `.env` already points at a configured (non-empty) app. */144function alreadyConfigured(existing: string): boolean {145 const parsed = parseDotenv(existing);146 return isNonEmptyString(parsed.JAZZ_APP_ID);147}148149/** The outcome of a provisioning run, for the caller to report. */150export type ProvisionResult = {151 app: GeneratedJazzApp;152 serverUrl: string;153 /** The absolute or relative path the environment was written to. */154 path: string;155 /** Whether an existing configured app was replaced. */156 replaced: boolean;157};158159export type ProvisionOptions = {160 /** The `.env` path to write. */161 path: string;162 /** Overwrite an already-configured `JAZZ_APP_ID`. */163 force?: boolean;164 /** Injectable network + filesystem for testing. */165 fetchImpl?: typeof fetch;166 readFile?: (path: string) => Promise<string>;167 writeFile?: (path: string, contents: string) => Promise<void>;168};169170const defaultReadFile = async (path: string): Promise<string> => {171 try {172 return await Deno.readTextFile(path);173 } catch (error) {174 if (error instanceof Deno.errors.NotFound) return "";175 throw error;176 }177};178179const defaultWriteFile = (path: string, contents: string): Promise<void> =>180 Deno.writeTextFile(path, contents);181182/**183 * Generates a Jazz app and writes its configuration into `options.path`,184 * refusing to clobber an already-configured app unless `force` is set. Returns185 * what was provisioned; never returns or logs the secrets.186 */187export async function provisionJazzApp(options: ProvisionOptions): Promise<ProvisionResult> {188 const readFile = options.readFile ?? defaultReadFile;189 const writeFile = options.writeFile ?? defaultWriteFile;190 const existing = await readFile(options.path);191 const replaced = alreadyConfigured(existing);192 if (replaced && !options.force) {193 throw new ProvisionError(194 `${options.path} already configures JAZZ_APP_ID; rerun with --force to replace it, or edit the file by hand`,195 );196 }197 const app = await generateJazzApp(options.fetchImpl);198 const serverUrl = JAZZ_MANAGED_SERVER_URL;199 const merged = mergeEnv(existing, environmentForApp(app, serverUrl));200 await writeFile(options.path, merged);201 return { app, serverUrl, path: options.path, replaced };202}203204// A defensive guard so a future edit to the env-name list cannot silently drop205// one of the four names this module writes.206const _names: readonly string[] = environmentNames;207if (208 !["JAZZ_APP_ID", "JAZZ_SERVER_URL", "JAZZ_ADMIN_SECRET", "BACKEND_SECRET"].every((name) =>209 _names.includes(name)210 )211) {212 throw new Error("provision: environment name list drifted from the four provisioned names");213}