scripts/loader.ts

403 lines
14 KiB
TypeScript

#!/usr/bin/env -S deno run --allow-net --allow-read --allow-write
/**
* loader.ts
* Remote script loader that:
* - Exposes a `scripts` command
* - Fetches available remote scripts (*.ts)
* - Provides autocomplete for them
* - Executes scripts remotely via Deno
* - Supports version selection via name@ref (branch or tag)
* - Optionally spawns a lightweight daemon to serve completions
*
* This script emits shell code for eval().
* eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet "https://git.kkarolis.lt/Karolis/scripts/raw/branch/${LOADER_REF}/loader.ts")"
* eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet loader.ts)"
*/
const BASE_RAW = "https://git.kkarolis.lt/Karolis/scripts/raw";
const BASE_API = "https://git.kkarolis.lt/api/v1/repos/Karolis/scripts";
// The loader's default ref; can be overridden by env var LOADER_REF
const LOADER_REF = Deno.env.get("LOADER_REF") ?? "main";
const PORT_FILE = Deno.env.get("SCRIPTS_DAEMON_PORT_FILE") ?? "/tmp/scripts-daemon.port";
function readPersistedPort(): number | null {
try {
const s = Deno.readTextFileSync(PORT_FILE).trim();
const p = Number(s);
return Number.isFinite(p) && p > 0 ? p : null;
} catch (_) { return null; }
}
function chooseDaemonPort(): number {
const envPort = Deno.env.get("SCRIPTS_DAEMON_PORT");
if (envPort) return Number(envPort);
const persisted = readPersistedPort();
if (persisted) return persisted;
// Default to 0 and let OS pick; will capture actual port
return 0;
}
let DAEMON_PORT = chooseDaemonPort();
// Removed cache-related code
async function fetchScriptList(ref: string = LOADER_REF): Promise<string[]> {
const apiUrl = `${BASE_API}/contents/scripts?ref=${encodeURIComponent(ref)}`;
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`Failed to fetch scripts (${ref}): ${response.statusText}`);
}
const scripts = await response.json();
return scripts
.filter((entry: { type?: string; name: string }) => entry.name.endsWith(".ts"))
.map((script: { name: string }) => script.name.replace(/\.ts$/, ""));
}
function _buildRawUrl(cmd: string, ref: string): string {
// Heuristic: tags typically start with 'v', otherwise treat as branch
const isTag = /^v[0-9]/.test(ref);
const kind = isTag ? "tag" : "branch";
// Scripts now live under the 'scripts/' subdirectory
return `${BASE_RAW}/${kind}/${encodeURIComponent(ref)}/scripts/${encodeURIComponent(cmd)}.ts`;
}
// --- Daemon mode -----------------------------------------------------
// Provides quick completions via a local HTTP server using fetch for discovery.
async function startDaemon(port: number = DAEMON_PORT) {
// In-memory cache with TTL
const cache = new Map<string, { data: unknown; expires: number }>();
const CACHE_TTL = 60_000; // 60 seconds
// HTTP cache for script modules with ETag/Last-Modified support
interface HttpCacheEntry {
module: unknown;
etag?: string;
lastModified?: string;
expires: number;
}
const httpCache = new Map<string, HttpCacheEntry>();
const HTTP_CACHE_TTL = 300_000; // 5 minutes before revalidation
const getCached = async <T>(key: string, init: () => Promise<T>): Promise<T> => {
const entry = cache.get(key);
if (entry && entry.expires > Date.now()) {
return entry.data as T;
}
// Cache miss or expired - call init function
const data = await init();
cache.set(key, { data, expires: Date.now() + CACHE_TTL });
return data;
};
const parseCacheControl = (header: string | null): number => {
if (!header) return HTTP_CACHE_TTL;
const maxAgeMatch = header.match(/max-age=(\d+)/);
if (maxAgeMatch) {
return parseInt(maxAgeMatch[1], 10) * 1000; // Convert seconds to milliseconds
}
return HTTP_CACHE_TTL;
};
const getCachedScript = async (scriptUrl: string): Promise<unknown> => {
const cached = httpCache.get(scriptUrl);
const now = Date.now();
// If cache is fresh, return immediately
if (cached && cached.expires > now) {
return cached.module;
}
// If cache exists but expired, try conditional request
if (cached) {
const headers: Record<string, string> = {};
if (cached.etag) headers["If-None-Match"] = cached.etag;
if (cached.lastModified) headers["If-Modified-Since"] = cached.lastModified;
try {
const response = await fetch(scriptUrl, { headers });
// 304 Not Modified - use cached version
if (response.status === 304) {
const cacheControl = response.headers.get("cache-control");
const ttl = parseCacheControl(cacheControl);
cached.expires = now + ttl;
return cached.module;
}
// 200 OK - fetch fresh version
if (response.ok) {
const module = await import(scriptUrl + "?t=" + now);
const cacheControl = response.headers.get("cache-control");
const ttl = parseCacheControl(cacheControl);
httpCache.set(scriptUrl, {
module,
etag: response.headers.get("etag") || undefined,
lastModified: response.headers.get("last-modified") || undefined,
expires: now + ttl
});
return module;
}
} catch (_) {
// On error, try to use stale cache if available
if (cached.module) return cached.module;
}
}
// No cache or revalidation failed - fresh import
const module = await import(scriptUrl + "?t=" + now);
const headResponse = await fetch(scriptUrl, { method: "HEAD" }).catch(() => null);
const cacheControl = headResponse?.headers.get("cache-control") || null;
const ttl = parseCacheControl(cacheControl);
httpCache.set(scriptUrl, {
module,
etag: headResponse?.headers.get("etag") || undefined,
lastModified: headResponse?.headers.get("last-modified") || undefined,
expires: now + ttl
});
return module;
};
const handler = async (req: Request): Promise<Response> => {
const url = new URL(req.url);
if (url.pathname === "/health") {
return new Response("ok", { status: 200 });
}
if (url.pathname === "/complete") {
try {
// Extract context from query params
const _shell = url.searchParams.get("shell") ?? "bash"; // bash | zsh
const cur = url.searchParams.get("cur") ?? "";
const cword = Number(url.searchParams.get("cword") ?? "1");
const wordsParam = url.searchParams.get("words") ?? ""; // space-joined words
const ref = url.searchParams.get("ref") ?? LOADER_REF;
const words = wordsParam ? wordsParam.split(" ") : [];
// Helpers
const getRefs = (): Promise<string[]> => {
return getCached("refs", async () => {
const [branchesJson, tagsJson] = await Promise.all([
fetch(`${BASE_API}/branches`).then(r => r.json()),
fetch(`${BASE_API}/tags`).then(r => r.json())
]);
const branches = Array.isArray(branchesJson) ? branchesJson.map((b: { name: string }) => b.name) : [];
const tags = Array.isArray(tagsJson) ? tagsJson.map((t: { name: string }) => t.name) : [];
return [...branches, ...tags];
});
};
// Helper to fetch completions from script
const getScriptCompletions = async (
scriptName: string,
scriptRef: string,
currentWord: string,
allWords: string[],
currentPosition: number
): Promise<string[]> => {
try {
const scriptUrl = _buildRawUrl(scriptName, scriptRef);
// Use cached script module with HTTP caching
const module = await getCachedScript(scriptUrl);
// Call getCompletions function if it exists
if (module && typeof (module as { getCompletions?: unknown }).getCompletions === 'function') {
// Pass context to the completion function
const scriptArgs = allWords.slice(2); // Args after script name
const ctx = {
args: scriptArgs,
current: currentWord,
position: currentPosition - 2 // Position relative to script args
};
type CompletionFn = (ctx: { args: string[]; current: string; position: number }) => string[] | Promise<string[]>;
const result = (module as { getCompletions: CompletionFn }).getCompletions(ctx);
// Handle both sync and async completion functions
return await Promise.resolve(result);
}
return [];
} catch (_) {
return [];
}
};
// Cached script list fetcher
const getCachedScriptList = (ref: string): Promise<string[]> => {
return getCached(`scripts:${ref}`, () => fetchScriptList(ref));
};
// Decide suggestions based on context
let suggestions: string[] = [];
if (cword <= 1) {
// Completing the first argument (script name or name@ref)
if (cur.includes("@")) {
const refs = await getRefs();
suggestions = refs.map(r => `@${r}`);
} else {
const names = await getCachedScriptList(ref);
suggestions = names;
}
} else {
// Completing script arguments
const first = words[1] ?? "";
let sname = first;
let _sref = ref;
if (first.includes("@")) {
const [n, r] = first.split("@");
sname = n;
_sref = r || ref;
}
suggestions = await getScriptCompletions(sname, _sref, cur, words, cword);
}
return new Response(suggestions.join("\n"), { headers: { "content-type": "text/plain" } });
} catch (e) {
return new Response(String(e), { status: 500, headers: { "content-type": "text/plain" } });
}
}
return new Response("not found", { status: 404 });
};
const server = Deno.serve({ port, onListen: ({ port: actualPort }) => {
// Persist the actual port once server is listening
try {
Deno.writeTextFileSync(PORT_FILE, String(actualPort));
DAEMON_PORT = actualPort;
} catch (_) {
// Best-effort port persistence; ignore failures
}
} }, handler);
await server.finished; // Keep process alive
}
async function ensureDaemonRunning(port: number = DAEMON_PORT) {
try {
const res = await fetch(`http://127.0.0.1:${port}/health`);
if (res.ok) return; // already running
} catch (_) {
// not running; spawn detached daemon
}
const thisFile = new URL(import.meta.url).pathname;
const cmd = new Deno.Command(Deno.execPath(), {
args: ["run", "--allow-net", "--allow-read", "--allow-env", "--allow-write", thisFile, "--daemon", "--port", String(port)],
stdout: "null",
stderr: "null",
stdin: "null",
});
const child = cmd.spawn();
child.unref();
// Give daemon a moment to start
await new Promise(resolve => setTimeout(resolve, 100));
}
// If invoked with --daemon, run the server and exit
if (Deno.args.includes("--daemon")) {
const idx = Deno.args.indexOf("--port");
if (idx >= 0 && Deno.args[idx + 1]) {
DAEMON_PORT = Number(Deno.args[idx + 1]);
}
await startDaemon(DAEMON_PORT);
Deno.exit(0);
}
// Try to ensure daemon is running to serve fast completions
await ensureDaemonRunning(DAEMON_PORT);
const scripts = await fetchScriptList(LOADER_REF);
// Output shell code
console.log(`
# --- Remote Script Loader (via loader.ts) -----------------------------
scripts() {
local raw="$1"
if [ -z "$raw" ]; then
echo "Available remote scripts (@${LOADER_REF}):";
# Query daemon for fresh script list
local _port
if [ -f "${PORT_FILE}" ]; then _port=$(cat "${PORT_FILE}"); else _port=${DAEMON_PORT}; fi
local list=$(curl -fs --get \
--data-urlencode "shell=bash" \
--data-urlencode "cur=" \
--data-urlencode "cword=1" \
--data-urlencode "words=scripts" \
--data-urlencode "ref=${LOADER_REF}" \
"http://127.0.0.1:${'${'}_port}/complete" 2>/dev/null)
if [ -n "$list" ]; then
echo "$list" | tr ' ' '\n'
else
# Fallback to static list if daemon unavailable
printf "%s\n" ${scripts.map(s => `"${s}"`).join(" ")};
fi
return 1;
fi
shift
local name="$raw"
local ref="${LOADER_REF}"
if [[ "$raw" == *"@"* ]]; then
name="\${raw%@*}"
ref="\${raw#*@}"
if [ -z "$ref" ]; then ref="${LOADER_REF}"; fi
fi
local url
if [[ "$ref" == v* ]]; then
url="${BASE_RAW}/tag/$ref/scripts/$name.ts"
else
url="${BASE_RAW}/branch/$ref/scripts/$name.ts"
fi
if ! curl -fsI "$url" >/dev/null 2>&1; then
echo "Error: script '$name' not found at ref '$ref'.";
return 127;
fi
local import_map_url
if [[ "$ref" == v* ]]; then
import_map_url="${BASE_RAW}/tag/$ref/import_map.json"
else
import_map_url="${BASE_RAW}/branch/$ref/import_map.json"
fi
deno run --allow-all --no-config --import-map="$import_map_url" "$url" "$@";
}
# --- Autocompletion ---------------------------------------------------
_script_completions() {
local cur="\${COMP_WORDS[COMP_CWORD]}";
# Query local daemon for freshest completions at loader ref
local list
local _port
if [ -f "${PORT_FILE}" ]; then _port=$(cat "${PORT_FILE}"); else _port=${DAEMON_PORT}; fi
list=$(curl -fs --get \
--data-urlencode "shell=bash" \
--data-urlencode "cur=$cur" \
--data-urlencode "cword=\${COMP_CWORD}" \
--data-urlencode "words=\${COMP_WORDS[*]}" \
--data-urlencode "ref=${LOADER_REF}" \
"http://127.0.0.1:${'${'}_port}/complete" | tr '\n' ' ')
COMPREPLY=( $(compgen -W "$list" -- "$cur") );
}
if [ -n "$BASH_VERSION" ]; then
complete -F _script_completions scripts;
fi
if [ -n "$ZSH_VERSION" ]; then
_script_completions_zsh() {
local list
local _port
if [ -f "${PORT_FILE}" ]; then _port=$(cat "${PORT_FILE}"); else _port=${DAEMON_PORT}; fi
list=$(curl -fs --get \
--data-urlencode "shell=zsh" \
--data-urlencode "cur=$CURRENT" \
--data-urlencode "cword=$COMP_CWORD" \
--data-urlencode "words=${'${'}words[*]}" \
--data-urlencode "ref=${LOADER_REF}" \
"http://127.0.0.1:${'${'}_port}/complete" | tr '\n' ' ')
reply=( $list );
}
compctl -K _script_completions_zsh scripts;
fi
# ----------------------------------------------------------------------
`);