clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat/source-stable-push
nzip / cli / commands / app.ts
app.ts 7.2K
1import { isAbsolute, relative, resolve } from "@std/path";2import { isValidName } from "@nzip/shared";3import { ApiClient } from "../lib/api.ts";4import type { Config } from "../lib/config.ts";5import { assertVaultAllowed } from "../lib/config.ts";6import { bold, cyan, dim, emit, fail, green } from "../lib/fmt.ts";7import { cmdPush } from "./push.ts";89const APP_CONFIG_FILE = "nzip.app.json";1011export interface NzipAppConfig {12 v: 1;13 framework: "lofi";14 target: string;15 address: string;16 origin: string;17 build: { task: string; output: string };18}1920interface AppFlags {21 framework?: string;22 output?: string;23 buildTask?: string;24}2526async function readAppConfig(optional = false): Promise<NzipAppConfig | null> {27 let text: string;28 try {29 text = await Deno.readTextFile(APP_CONFIG_FILE);30 } catch (cause) {31 if (optional && cause instanceof Deno.errors.NotFound) return null;32 return fail(`no ${APP_CONFIG_FILE} found — run \`nzip app init <alias>\` first`);33 }34 try {35 const value = JSON.parse(text) as Partial<NzipAppConfig>;36 if (37 value.v !== 1 || value.framework !== "lofi" ||38 typeof value.target !== "string" || typeof value.address !== "string" ||39 typeof value.origin !== "string" || typeof value.build?.task !== "string" ||40 typeof value.build.output !== "string"41 ) {42 throw new Error("unsupported shape");43 }44 return value as NzipAppConfig;45 } catch (cause) {46 return fail(`invalid ${APP_CONFIG_FILE}: ${(cause as Error).message}`);47 }48}4950function splitAppTarget(raw: string, defaultVault: string | null): [string, string] {51 const separator = raw.indexOf(":");52 const vault = separator === -1 ? defaultVault : raw.slice(0, separator);53 const alias = separator === -1 ? raw : raw.slice(separator + 1);54 if (!vault) fail("server has no permanent default vault — use vault:alias");55 if (!isValidName(vault) || !isValidName(alias)) {56 fail("app target must be an alias or vault:alias using lowercase names");57 }58 return [vault, alias];59}6061function containsStringLiteral(source: string, value: string): boolean {62 return source.includes(JSON.stringify(value)) ||63 source.includes(`'${value.replaceAll("'", "\\'")}'`);64}6566async function cmdAppInit(67 config: Config,68 rawTarget: string | undefined,69 flags: AppFlags,70): Promise<void> {71 const existing = await readAppConfig(true);72 if (existing && rawTarget === undefined) rawTarget = existing.target;73 if (!rawTarget) fail("usage: nzip app init <alias|vault:alias> [--framework lofi]");74 if (flags.framework !== undefined && flags.framework !== "lofi") {75 fail("the first-class app host currently supports --framework lofi");76 }7778 const api = new ApiClient(config);79 const status = await api.status();80 const [vault, alias] = splitAppTarget(rawTarget, status.defaultVaults?.permanent ?? null);81 assertVaultAllowed(vault, config);82 const target = `${vault}:${alias}`;83 if (existing && existing.target !== target) {84 fail(`${APP_CONFIG_FILE} already reserves ${existing.target}; refusing to replace its origin`);85 }8687 const reservation = await api.initApp(vault, alias);88 const origin = new URL(reservation.url).origin;89 if (existing && (existing.address !== reservation.address || existing.origin !== origin)) {90 fail(`server reservation for ${target} does not match ${APP_CONFIG_FILE}`);91 }92 const appConfig: NzipAppConfig = existing ?? {93 v: 1,94 framework: "lofi",95 target,96 address: reservation.address,97 origin,98 build: {99 task: flags.buildTask ?? "build",100 output: flags.output ?? "dist",101 },102 };103 await Deno.writeTextFile(APP_CONFIG_FILE, `${JSON.stringify(appConfig, null, 2)}\n`);104105 emit(106 () => {107 console.log(`${green("✓")} reserved ${bold(target)} → ${cyan(reservation.url)}`);108 console.log(dim(` wrote ${APP_CONFIG_FILE} (safe to commit; contains no token)`));109 console.log(110 dim(` add ${JSON.stringify(origin)} to lofi credentialOrigins, then run nzip app deploy`),111 );112 },113 { ok: true, ...reservation, framework: appConfig.framework, config: APP_CONFIG_FILE },114 );115}116117async function requireFile(path: string): Promise<void> {118 try {119 if (!(await Deno.stat(path)).isFile) throw new Error("not a file");120 } catch {121 fail(`lofi build output is missing ${path}`);122 }123}124125async function cmdAppDeploy(config: Config): Promise<void> {126 const app = await readAppConfig();127 if (!app) return;128 const root = Deno.cwd();129 const output = resolve(root, app.build.output);130 const outputRelative = relative(root, output);131 if (132 isAbsolute(app.build.output) || outputRelative === ".." || outputRelative.startsWith(`..${"/"}`)133 ) {134 fail(`${APP_CONFIG_FILE} build.output must stay inside the app directory`);135 }136137 let appSource: string;138 try {139 appSource = await Deno.readTextFile(resolve(root, "src/app.ts"));140 } catch {141 return fail("lofi app source src/app.ts is missing");142 }143 if (!containsStringLiteral(appSource, app.origin)) {144 fail(145 `src/app.ts must include ${146 JSON.stringify(app.origin)147 } in credentialOrigins before deployment`,148 );149 }150 const hostname = new URL(app.origin).hostname;151 if (/\bpasskey\s*:/.test(appSource) && !containsStringLiteral(appSource, hostname)) {152 fail(`src/app.ts passkey.rpId must be pinned to ${JSON.stringify(hostname)}`);153 }154155 emit(() => console.log(dim(` running deno task ${app.build.task} with LOFI_BASE_PATH=/`)));156 const built = await new Deno.Command("deno", {157 args: ["task", app.build.task],158 cwd: root,159 env: { LOFI_BASE_PATH: "/" },160 stdin: "inherit",161 stdout: "inherit",162 stderr: "inherit",163 }).spawn().status;164 if (!built.success) fail(`lofi build failed with exit code ${built.code}`);165166 await Promise.all([167 requireFile(resolve(output, "index.html")),168 requireFile(resolve(output, "manifest.webmanifest")),169 requireFile(resolve(output, "sw.js")),170 requireFile(resolve(output, "lofi-precache.json")),171 requireFile(resolve(output, "lofi-schema.json")),172 requireFile(resolve(output, "lofi-build.json")),173 ]);174 let buildInfo: { lofiVersion?: unknown; sourceHash?: unknown; basePath?: unknown; csp?: unknown };175 try {176 buildInfo = JSON.parse(await Deno.readTextFile(resolve(output, "lofi-build.json")));177 } catch {178 return fail("dist/lofi-build.json is not valid JSON");179 }180 if (181 typeof buildInfo.lofiVersion !== "string" || typeof buildInfo.sourceHash !== "string" ||182 buildInfo.basePath !== "/" ||183 (buildInfo.csp !== undefined && typeof buildInfo.csp !== "string")184 ) {185 fail("lofi build identity is invalid or was not built for the origin root");186 }187188 await cmdPush(189 config,190 output,191 app.target,192 undefined,193 undefined,194 false,195 false,196 typeof buildInfo.csp === "string" ? { contentSecurityPolicy: buildInfo.csp } : {},197 );198}199200export async function cmdApp(201 config: Config,202 rest: string[],203 flags: AppFlags,204): Promise<void> {205 const [sub, target, ...extra] = rest;206 if (extra.length > 0) fail(`too many arguments for nzip app ${sub ?? ""}`);207 if (sub === "init") return await cmdAppInit(config, target, flags);208 if (sub === "deploy") {209 if (target !== undefined) fail("usage: nzip app deploy");210 return await cmdAppDeploy(config);211 }212 fail("usage: nzip app <init|deploy> ...");213}