@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
217 lines (216 loc) • 9.29 kB
JavaScript
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));
});
}