Files
Termix/packages/plugin-sdk/cli/commands/dev.mjs
T

159 lines
4.7 KiB
JavaScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { build } from "./build.mjs";
import { pack } from "./pack.mjs";
import { readManifest } from "../lib/plugin-dir.mjs";
const SOURCE_DIRS = new Set(["src", "locales", "migrations"]);
const SOURCE_FILES = new Set(["manifest.json", "package.json", "README.md"]);
function readOption(args, name) {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (!value) throw new Error(`${name} needs a value`);
return value;
}
/** True when a changed path (relative to the plugin) feeds its build. */
export function isSourceChange(filename) {
if (!filename) return false;
const parts = String(filename).replaceAll("\\", "/").split("/");
if (parts.length === 1) return SOURCE_FILES.has(parts[0]);
return SOURCE_DIRS.has(parts[0]);
}
export function resolveDevOptions(args, env = process.env) {
const server = (
readOption(args, "--server") ??
env.TERMIX_SERVER_URL ??
"http://localhost:30001"
).replace(/\/+$/, "");
const key = readOption(args, "--key") ?? env.TERMIX_API_KEY ?? null;
if (!key) {
throw new Error(
"An admin API key is needed. Pass --key tmx_... or set TERMIX_API_KEY.",
);
}
return { server, key, once: args.includes("--once") };
}
const HINTS = {
DEVELOPER_MODE_OFF:
"Turn on developer mode in Admin settings, under Plugins, then save again.",
SIGNED_ONLY:
"The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
ALREADY_INSTALLED:
"Uninstall the copy installed from the registry first, then save again.",
};
async function request(fetchImpl, url, init) {
const response = await fetchImpl(url, init);
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { error: text };
}
if (!response.ok) {
const code = body?.code;
const hint = code && HINTS[code] ? `\n ${HINTS[code]}` : "";
throw new Error(
`${response.status} ${body?.error ?? response.statusText}${hint}`,
);
}
return body;
}
/** Uploads a packed .tmxplug and installs it, agreeing to what it asks for. */
export async function pushPlugin(
file,
{ server, key },
fetchImpl = globalThis.fetch,
) {
const auth = { Authorization: `Bearer ${key}` };
const preview = await request(fetchImpl, `${server}/plugins/upload`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/octet-stream" },
body: fs.readFileSync(file),
});
const result = await request(
fetchImpl,
`${server}/plugins/upload/${preview.token}/install`,
{
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ capabilities: preview.capabilities }),
},
);
return { preview, result };
}
/**
* Builds, packs and installs the plugin on a running Termix server, then
* does it again on every source change. The server needs developer mode on.
*/
export async function dev({ cwd, args = [] }) {
const options = resolveDevOptions(args);
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugin-dev-"));
let lastCapabilities = null;
const cycle = async () => {
const started = Date.now();
await build({ cwd });
const file = await pack({ cwd, args: ["--out", outDir] });
const { preview, result } = await pushPlugin(file, options);
fs.rmSync(file, { force: true });
const capabilities = preview.capabilities.join(", ") || "none";
if (capabilities !== lastCapabilities) {
console.log(` capabilities ${capabilities}`);
lastCapabilities = capabilities;
}
console.log(
`installed ${result.id}@${result.version} on ${options.server} (${result.state}, ${Date.now() - started} ms)`,
);
};
try {
await cycle();
} catch (error) {
if (options.once) throw error;
console.error(error?.message ?? error);
}
if (options.once) return;
const { id } = readManifest(cwd);
console.log(`watching ${id} for changes, Ctrl+C to stop`);
let timer = null;
let running = false;
let again = false;
const fire = async () => {
if (running) {
again = true;
return;
}
running = true;
try {
await cycle();
} catch (error) {
console.error(error?.message ?? error);
} finally {
running = false;
if (again) {
again = false;
void fire();
}
}
};
fs.watch(cwd, { recursive: true }, (_event, filename) => {
if (!isSourceChange(filename)) return;
clearTimeout(timer);
timer = setTimeout(() => void fire(), 300);
});
await new Promise(() => {});
}