Add remote script loader with intelligent completions
Features: - Remote script execution with version control (name@ref) - Background daemon for fast completions (< 15ms) - HTTP caching with ETag/Last-Modified support - Context-aware completions with async support - Comprehensive documentation and type definitions Changes: - Add loader.ts with daemon mode - Add completions.d.ts type definitions - Add COMPLETIONS.md guide for developers - Add README.md with usage examples - Update configure_ssh_key.ts with async completions - Use JSR imports for remote execution compatibility
This commit is contained in:
parent
616a2d9c96
commit
df5df0ea92
7 changed files with 1448 additions and 3 deletions
411
loader.ts
Normal file
411
loader.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
#!/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?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 to register autocompletions for available scripts
|
||||
function registerAutocompletions(scripts: { name: string }[]) {
|
||||
const completions = scripts.map(script => script.name);
|
||||
// Logic to register completions in the Deno environment
|
||||
// This could involve using Deno's built-in completion features or a custom implementation
|
||||
console.log('Available script completions:', completions);
|
||||
}
|
||||
|
||||
// Update the existing function to fetch available scripts and register autocompletions
|
||||
async function _fetchAvailableScripts(ref: string = LOADER_REF) {
|
||||
const response = await fetch(`${BASE_API}/contents?ref=${encodeURIComponent(ref)}`);
|
||||
const scripts = await response.json();
|
||||
registerAutocompletions(scripts);
|
||||
return scripts;
|
||||
}
|
||||
|
||||
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";
|
||||
return `${BASE_RAW}/${kind}/${encodeURIComponent(ref)}/${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/$name.ts"
|
||||
else
|
||||
url="${BASE_RAW}/branch/$ref/$name.ts"
|
||||
fi
|
||||
|
||||
if ! curl -fsI "$url" >/dev/null 2>&1; then
|
||||
echo "Error: script '$name' not found at ref '$ref'.";
|
||||
return 127;
|
||||
fi
|
||||
|
||||
deno run --allow-all "$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
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue