403 lines
14 KiB
TypeScript
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
|
|
|
|
# ----------------------------------------------------------------------
|
|
`);
|