UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

217 lines (216 loc) 9.29 kB
import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; import { logError, logInfo, logSuccess, logWarn } from "../utils/log.js"; import { agentApiFetch, agentApiSend, describeHttpError, describeNetworkError, resolveTarget, } from "./agent-api-client.js"; const DEFAULT_AUTH_CONTEXT = "mesh.dev"; const UPLOAD_CONCURRENCY = 4; const SKIP_ENTRIES = new Set([".DS_Store", "Thumbs.db", ".git"]); async function describeSiteHttpError(res, target, site) { if (res.status === 403) { return (`not allowed to publish "${site}" — you are authenticated but lack the grant. ` + `Need one of: sites:${site}:publish, sites:*:publish, or studio-admin.`); } if (res.status === 404) { return `no such site or version for "${site}" — check \`mesh site versions ${site}\`.`; } return describeHttpError(res, target, { id: site }); } async function hashFile(absolute) { const h = createHash("sha256"); await new Promise((res, rej) => { createReadStream(absolute) .on("data", (chunk) => h.update(chunk)) .on("end", () => res()) .on("error", rej); }); return h.digest("hex"); } async function walk(root) { const out = []; async function visit(dir) { for (const entry of await readdir(dir, { withFileTypes: true })) { if (SKIP_ENTRIES.has(entry.name)) continue; const absolute = join(dir, entry.name); if (entry.isSymbolicLink()) { logWarn(`skipping symlink ${relative(root, absolute)}`); continue; } if (entry.isDirectory()) { await visit(absolute); continue; } if (!entry.isFile()) continue; const info = await stat(absolute); out.push({ path: relative(root, absolute).split(sep).join("/"), absolute, hash: await hashFile(absolute), size: info.size, }); } } await visit(root); return out.sort((a, b) => a.path.localeCompare(b.path)); } function human(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } async function pooled(items, limit, worker) { let next = 0; const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { for (;;) { const index = next; next += 1; if (index >= items.length) return; await worker(items[index]); } }); await Promise.all(runners); } async function publish(name, dir, opts, target) { const root = resolve(dir); const info = await stat(root).catch(() => null); if (!info?.isDirectory()) throw new Error(`${root} is not a directory`); logInfo(`Hashing ${root}…`); const files = await walk(root); if (files.length === 0) throw new Error(`${root} contains no files`); const total = files.reduce((n, f) => n + f.size, 0); logInfo(`${files.length} files, ${human(total)}`); if (!files.some((f) => f.path === "index.html")) { logWarn("no index.html at the root — deep links and the site root will 404"); } const unique = [...new Set(files.map((f) => f.hash))]; const checkRes = await agentApiSend(target, `/v1/sites/${encodeURIComponent(name)}/blobs/check`, { method: "POST", json: { hashes: unique }, }); if (!checkRes.ok) throw new Error(await describeSiteHttpError(checkRes, target, name)); const { missing } = (await checkRes.json()); const missingSet = new Set(missing); const toUpload = files.filter((f) => missingSet.has(f.hash)); const seen = new Set(); const uploads = toUpload.filter((f) => (seen.has(f.hash) ? false : (seen.add(f.hash), true))); const uploadBytes = uploads.reduce((n, f) => n + f.size, 0); const reused = files.length - uploads.length; logInfo(`${uploads.length} to upload (${human(uploadBytes)}); ${reused} already present (${human(total - uploadBytes)})`); let done = 0; await pooled(uploads, UPLOAD_CONCURRENCY, async (file) => { const body = await readFile(file.absolute); const res = await agentApiSend(target, `/v1/sites/${encodeURIComponent(name)}/blobs/${file.hash}`, { method: "PUT", body, contentType: "application/octet-stream" }); if (!res.ok) { throw new Error(`upload failed for ${file.path} (${human(file.size)}): ` + (await describeSiteHttpError(res, target, name))); } done += 1; logInfo(` [${done}/${uploads.length}] ${file.path} (${human(file.size)})`); }); const fileMap = Object.fromEntries(files.map((f) => [f.path, f.hash])); const commitRes = await agentApiSend(target, `/v1/sites/${encodeURIComponent(name)}/versions`, { method: "POST", json: { ...(opts.version ? { version: opts.version } : {}), files: fileMap, activate: opts.activate }, }); if (!commitRes.ok) { if (commitRes.status === 409) { const body = (await commitRes.json().catch(() => ({}))); throw new Error(`commit rejected: ${body.missing?.length ?? "some"} blobs are missing server-side. ` + `Re-run to upload them.`); } throw new Error(await describeSiteHttpError(commitRes, target, name)); } const { version, activated } = (await commitRes.json()); logSuccess(`Published ${name} version ${version}`); if (activated) { logInfo(`Live at /sites/${name}/`); } else { logInfo(`Not activated. Serve it with: mesh site rollback ${name} ${version}`); logInfo(`Or preview it at /sites/${name}/@${version}/`); } } async function listVersions(name, target) { const res = await agentApiFetch(target, `/v1/sites/${encodeURIComponent(name)}/versions`); if (!res.ok) throw new Error(await describeSiteHttpError(res, target, name)); const { versions } = (await res.json()); if (versions.length === 0) { logInfo(`No versions published for "${name}".`); return; } for (const v of versions) { const marker = v.current ? "*" : " "; logInfo(`${marker} ${v.version} ${v.createdAt} ${String(v.fileCount).padStart(5)} files ${v.publisher}`); } logInfo(""); logInfo("* = currently served"); } async function rollback(name, version, target) { const res = await agentApiSend(target, `/v1/sites/${encodeURIComponent(name)}/rollback`, { method: "POST", json: { version }, }); if (!res.ok) throw new Error(await describeSiteHttpError(res, target, name)); logSuccess(`${name} now serves ${version}`); } async function run(opts, body) { let target; try { target = resolveTarget(opts); } catch (error) { logError(error instanceof Error ? error.message : String(error)); process.exitCode = 1; return; } try { await body(target); } catch (error) { const message = error instanceof Error ? error.message : String(error); logError(message); const hint = describeNetworkError(error, target); if (hint !== message) logError(hint); process.exitCode = 1; } } const targetOptions = (cmd) => cmd .option("--target <name>", "Named agent target from the agent-targets registry") .option("--api-url <url>", "Agent API URL (overrides --target; ad-hoc, no registry lookup)") .option("--context <ctx>", `Zitadel auth context, used with --api-url (default: ${DEFAULT_AUTH_CONTEXT})`); export function registerSiteCommands(program) { const site = program .command("site") .description("Publish and manage OAuth-protected static sites served by Studio"); targetOptions(site .command("publish <name> <dir>") .description("Publish a built directory as a new version\n\n" + "Uploads only files the server does not already hold, so a rebuild moves\n" + "only what changed.") .option("--version-id <id>", "Version id (default: a UTC timestamp; CI usually passes a git sha)") .option("--no-activate", "Publish without pointing the site at it")).action(async (name, dir, opts) => { await run(opts, (target) => publish(name, dir, { version: opts.versionId, activate: opts.activate }, target)); }); targetOptions(site.command("versions <name>").description("List published versions, newest first")).action(async (name, opts) => { await run(opts, (target) => listVersions(name, target)); }); targetOptions(site .command("rollback <name> <version>") .description("Point the site at an already-published version")).action(async (name, version, opts) => { await run(opts, (target) => rollback(name, version, target)); }); }