#!/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 { 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(); 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(); const HTTP_CACHE_TTL = 300_000; // 5 minutes before revalidation const getCached = async (key: string, init: () => Promise): Promise => { 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 => { 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 = {}; 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 => { 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 => { 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 => { 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; 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 => { 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 # ---------------------------------------------------------------------- `);