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:
Karolis2011 2025-11-29 23:22:35 +02:00
parent 616a2d9c96
commit df5df0ea92
7 changed files with 1448 additions and 3 deletions

447
COMPLETIONS.md Normal file
View file

@ -0,0 +1,447 @@
# Script Completions Guide
This guide explains how to add intelligent shell completions to your scripts in the remote script loader system.
## Overview
Scripts can export a `getCompletions` function that provides context-aware suggestions for command-line arguments. The loader daemon calls this function when users press TAB in their shell.
## Quick Start
Add this to your script:
```typescript
import type { CompletionContext } from "./completions.d.ts";
export function getCompletions(ctx: CompletionContext): string[] {
return ["--help", "--version", "--verbose"];
}
```
Or use async for API calls or async operations:
```typescript
import type { CompletionContext } from "./completions.d.ts";
export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
// Can now use await for async operations
return ["--help", "--version", "--verbose"];
}
```
That's it! The loader will automatically discover and use your completion function.
## The CompletionContext
Your function receives a context object with three properties:
```typescript
interface CompletionContext {
args: string[]; // All arguments provided so far
current: string; // The word being completed
position: number; // Position in the args array
}
```
### Example Context
If the user types:
```bash
scripts my-script --flag1 value1 --fl<TAB>
```
Your function receives:
```typescript
{
args: ["--flag1", "value1"],
current: "--fl",
position: 2
}
```
## Common Patterns
### 1. Basic Flag Completions
Return all available flags:
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
return [
"--input=",
"--output=",
"--verbose",
"--help"
];
}
```
### 2. Value Completions
Suggest values for flags with `=`:
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
const { current } = ctx;
if (current.startsWith("--format=")) {
return ["--format=json", "--format=yaml", "--format=xml"];
}
if (current.startsWith("--level=")) {
return ["--level=debug", "--level=info", "--level=warn", "--level=error"];
}
return ["--format=", "--level=", "--help"];
}
```
### 3. Avoiding Duplicate Flags
Filter out already-used flags:
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
const { args } = ctx;
// Check what's already provided
const hasVerbose = args.includes("--verbose");
const hasOutput = args.some(a => a.startsWith("--output"));
const flags = [];
if (!hasVerbose) flags.push("--verbose");
if (!hasOutput) flags.push("--output=");
flags.push("--help"); // Help can always be shown
return flags;
}
```
### 4. Mutually Exclusive Options
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
const { args } = ctx;
const hasJson = args.includes("--json");
const hasYaml = args.includes("--yaml");
// Don't suggest both formats at once
if (hasJson) {
return ["--verbose", "--help"]; // No --yaml
}
if (hasYaml) {
return ["--verbose", "--help"]; // No --json
}
return ["--json", "--yaml", "--verbose", "--help"];
}
```
### 5. Filesystem Completions
Discover files dynamically:
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
const { current } = ctx;
if (current.startsWith("--config=")) {
try {
const home = Deno.env.get("HOME") || "";
const configDir = `${home}/.config/myapp`;
const configs: string[] = [];
for (const entry of Deno.readDirSync(configDir)) {
if (entry.isFile && entry.name.endsWith(".json")) {
configs.push(`--config=${entry.name}`);
}
}
return configs.length > 0 ? configs : ["--config=config.json"];
} catch {
return ["--config=config.json"];
}
}
return ["--config=", "--help"];
}
```
### 6. Environment-Based Completions
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
const { current } = ctx;
if (current.startsWith("--user=")) {
// Suggest current user
const currentUser = Deno.env.get("USER") || "user";
return [`--user=${currentUser}`];
}
if (current.startsWith("--home=")) {
const home = Deno.env.get("HOME") || "";
return [`--home=${home}`];
}
return ["--user=", "--home=", "--help"];
}
```
### 7. Async Completions with API Calls
```typescript
export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
const { current } = ctx;
if (current.startsWith("--repo=")) {
try {
// Fetch repositories from GitHub API
const response = await fetch("https://api.github.com/user/repos", {
headers: {
"Authorization": `token ${Deno.env.get("GITHUB_TOKEN")}`
}
});
if (response.ok) {
const repos = await response.json();
return repos.map((r: { name: string }) => `--repo=${r.name}`);
}
} catch {
// Fallback on error
}
return ["--repo=<repository-name>"];
}
return ["--repo=", "--help"];
}
```
**Note**: The loader daemon caches script modules, but your async operations run on each completion. For better performance, consider returning fast fallback values while keeping the function synchronous, or implement your own caching within the script.
### 8. Positional Argument Completions
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
const { current, args, position } = ctx;
// Get non-flag arguments
const positionalArgs = args.filter(a => !a.startsWith("-"));
// First positional: suggest commands
if (positionalArgs.length === 0 && !current.startsWith("-")) {
return ["start", "stop", "restart", "status"];
}
// Second positional: suggest targets
if (positionalArgs.length === 1 && !current.startsWith("-")) {
return ["production", "staging", "development"];
}
// Otherwise suggest flags
return ["--force", "--verbose", "--help"];
}
```
## Best Practices
### Performance
- Keep completion functions fast (< 50ms recommended)
- Cache expensive operations if possible
- **Use async sparingly**: Async completions can make UX slower
- For API calls, implement caching or provide immediate fallback
- Use try-catch for filesystem operations
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
try {
// Fast filesystem lookup
const files = [...Deno.readDirSync(".")];
return files.map(f => f.name);
} catch {
// Fallback to safe defaults
return ["file.txt", "config.json"];
}
}
```
### User Experience
- Return empty array if no completions available
- Include both complete and partial suggestions
- Add `=` suffix for flags that need values
- Provide sensible defaults when dynamic lookup fails
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
const { current } = ctx;
// Partial match - suggests user needs to provide a value
if (current.startsWith("--port=")) {
return ["--port=3000", "--port=8080", "--port=8443"];
}
// Complete flags
return ["--port=", "--host=", "--help"];
}
```
### Documentation
- Add JSDoc comments to your completion function
- Document expected flag values
- Explain any complex completion logic
```typescript
/**
* Provides completions for the configure script.
*
* Supports:
* - --key=<path>: SSH key paths from ~/.ssh/
* - --port=<num>: Common SSH ports (22, 2222, 2200)
* - --alias=<name>: Auto-generated from target
*/
export function getCompletions(ctx: CompletionContext): string[] {
// ... implementation
}
```
## Testing Completions Locally
Test your completion function locally before pushing:
```bash
# Import and test directly
deno eval '
const m = await import("file:///path/to/your/script.ts");
const result = m.getCompletions({
args: ["--flag1"],
current: "--",
position: 1
});
console.log(result.join("\n"));
'
```
## Caching and Performance
The loader daemon caches:
- **Script modules**: 5 minutes (respects HTTP Cache-Control)
- **HTTP responses**: Uses ETag/Last-Modified for conditional requests
- **Metadata (refs, script lists)**: 60 seconds
Your completion function is called on every TAB press, but the script import is cached, so only your function execution time matters.
## Advanced Example
Complete example from `configure_ssh_key.ts`:
```typescript
import type { CompletionContext } from "./completions.d.ts";
export function getCompletions(ctx: CompletionContext): string[] {
const { current, args } = ctx;
// Complete SSH key paths
if (current.startsWith("--key=")) {
try {
const home = Deno.env.get("HOME") || "";
const sshDir = `${home}/.ssh`;
const keys: string[] = [];
for (const entry of Deno.readDirSync(sshDir)) {
if (entry.isFile && !entry.name.endsWith(".pub") &&
(entry.name.startsWith("id_") || entry.name.includes("key"))) {
keys.push(`--key=~/.ssh/${entry.name}`);
}
}
return keys.length > 0 ? keys : ["--key=~/.ssh/id_rsa"];
} catch {
return ["--key=~/.ssh/id_rsa", "--key=~/.ssh/id_ed25519"];
}
}
// Smart alias based on target
if (current.startsWith("--alias=")) {
const target = args.find(a => !a.startsWith("-"));
if (target && target.includes("@")) {
const [user, host] = target.split("@");
const hostPart = host.split(":")[0];
return [`--alias=${user}-${hostPart.replace(/\./g, "-")}`];
}
return ["--alias=<host-alias>"];
}
// Common ports
if (current.startsWith("--port=")) {
return ["--port=22", "--port=2222", "--port=2200"];
}
// Track what's been used
const hasGenerate = args.includes("--generate");
const hasKey = args.some(a => a.startsWith("--key"));
const flags: string[] = [];
// Mutually exclusive: --key and --generate
if (!hasGenerate && !hasKey) {
flags.push("--key=", "--generate");
}
flags.push("--alias=", "--port=", "--force", "--dry-run", "--help");
return flags;
}
```
## Troubleshooting
### Completions not working?
1. Check script exports `getCompletions`:
```typescript
export function getCompletions(ctx: CompletionContext): string[] { ... }
```
2. Verify the function signature matches:
```typescript
(ctx: { args: string[]; current: string; position: number }) => string[]
```
3. Test locally with the eval command above
4. Check daemon logs: `cat /tmp/daemon.log`
### Completions are slow?
- Profile your function execution time
- Avoid expensive I/O operations
- Cache results when possible
- Return early for invalid states
### Completions are incorrect?
- Log the context to understand what's being passed:
```typescript
export function getCompletions(ctx: CompletionContext): string[] {
console.error("DEBUG:", JSON.stringify(ctx));
return [...];
}
```
- Check daemon logs for your debug output
## Type Checking
Use the provided type definitions:
```typescript
import type { CompletionContext, CompletionFunction } from "./completions.d.ts";
export const getCompletions: CompletionFunction = (ctx) => {
// TypeScript will enforce correct parameter and return types
return ["--help"];
};
```

322
README.md Normal file
View file

@ -0,0 +1,322 @@
# Remote Script Loader
A lightweight, intelligent script loader for Deno that enables remote script execution with shell completions, version control, and caching.
## Features
- 🚀 **Remote Execution** - Run scripts directly from Git without cloning
- 🔄 **Version Control** - Execute scripts from specific branches or tags (`script@v1.0`)
- ⚡ **Smart Completions** - Context-aware shell completions with filesystem discovery
- 💾 **Intelligent Caching** - HTTP caching with ETag/Last-Modified support
- 🔒 **Fast Daemon** - Background daemon for instant completions (< 15ms)
- 📦 **Zero Config** - Works out of the box with bash and zsh
## Quick Start
### Installation
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
# Load remote script loader from main branch
export LOADER_REF=main
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet https://git.kkarolis.lt/Karolis/scripts/raw/branch/main/loader.ts)"
```
Or pin to a specific version:
```bash
# Load from a specific tag
export LOADER_REF=main
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet https://git.kkarolis.lt/Karolis/scripts/raw/tag/v1.0.0/loader.ts)"
```
Or use a local copy:
```bash
# Load from local file (for development)
export LOADER_REF=main
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet /path/to/scripts/loader.ts)"
```
### Using Custom Refs
Set the `LOADER_REF` environment variable to use a different default branch:
```bash
# Use a development branch by default
export LOADER_REF=develop
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet https://git.kkarolis.lt/Karolis/scripts/raw/branch/develop/loader.ts)"
```
### Basic Usage
```bash
# List available scripts
scripts
# Run a script
scripts configure_ssh_key user@host --key=~/.ssh/id_rsa
# Run script from a specific branch
scripts configure_ssh_key@develop user@host
# Run script from a specific tag
scripts configure_ssh_key@v1.0.0 user@host
```
### Shell Completions
Press `TAB` to get intelligent completions:
```bash
scripts <TAB> # Lists all available scripts
scripts conf<TAB> # Completes to configure_ssh_key
scripts configure_ssh_key@<TAB> # Lists available branches and tags
scripts configure_ssh_key --<TAB> # Shows script-specific flags
scripts configure_ssh_key --key=<TAB> # Suggests SSH keys from ~/.ssh/
```
## Configuration
### Environment Variables
- **`LOADER_REF`** - Default branch/tag to use (default: `main`)
- **`SCRIPTS_DAEMON_PORT`** - Force specific port for daemon (default: auto-assign)
- **`SCRIPTS_DAEMON_PORT_FILE`** - Port file location (default: `/tmp/scripts-daemon.port`)
### Examples
```bash
# Use staging branch by default
export LOADER_REF=staging
# Force daemon to use specific port
export SCRIPTS_DAEMON_PORT=54321
# Custom port file location
export SCRIPTS_DAEMON_PORT_FILE=~/.cache/scripts-daemon.port
```
## How It Works
1. **Initial Load**: When sourced, the loader:
- Spawns a background daemon for completions
- Fetches the list of available scripts
- Defines the `scripts` shell function
2. **Daemon**: A persistent HTTP server that:
- Caches script lists and branch/tag information (60s TTL)
- Caches script modules with HTTP ETag/Last-Modified (300s TTL)
- Provides fast completions (< 15ms after first request)
3. **Execution**: When you run a script:
- Validates the script exists on the remote
- Downloads and executes it with Deno
- All script arguments are passed through
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Shell (bash/zsh) │
│ scripts configure_ssh_key --key=~/.ssh/id_rsa <TAB>
└──────────────┬──────────────────────────────────────────────┘
├─ Execution: Direct to Deno
│ │
│ └─> Deno runs remote script
└─ Completion: Query daemon
┌──────────────────────┐
│ Daemon (port 54xxx) │
│ ─────────────────── │
│ • Caches metadata │
│ • Caches modules │
│ • HTTP conditional │
│ requests (304) │
└──────────────────────┘
├─> Script List API
├─> Branches/Tags API
└─> Script Module Import
```
## Writing Scripts
See [COMPLETIONS.md](./COMPLETIONS.md) for a comprehensive guide on adding intelligent completions to your scripts.
### Minimal Example
```typescript
#!/usr/bin/env -S deno run --allow-all
import type { CompletionContext } from "./completions.d.ts";
// Export completion function (optional)
export function getCompletions(ctx: CompletionContext): string[] {
return ["--verbose", "--help"];
}
// Your script logic
console.log("Hello from remote script!");
```
### Using Dependencies
For remote scripts to work, use explicit JSR imports instead of bare specifiers:
```typescript
// ❌ Won't work for remote scripts
import { parseArgs } from "@std/cli/parse-args";
// ✅ Use JSR imports for remote execution
import { parseArgs } from "jsr:@std/cli@^1.0.0/parse-args";
```
This ensures dependencies are resolved when the script is executed remotely via URL.
### Async Completions
```typescript
export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
const { current } = ctx;
if (current.startsWith("--key=")) {
// Discover SSH keys asynchronously
const keys: string[] = [];
for await (const entry of Deno.readDir(`${Deno.env.get("HOME")}/.ssh`)) {
if (entry.isFile && entry.name.startsWith("id_")) {
keys.push(`--key=~/.ssh/${entry.name}`);
}
}
return keys;
}
return ["--key=", "--help"];
}
```
## Performance
### Caching Strategy
| Resource | Cache Duration | Strategy |
|----------|----------------|----------|
| Script List | 60 seconds | In-memory |
| Branches/Tags | 60 seconds | In-memory |
| Script Modules | 300 seconds | HTTP ETag/Last-Modified |
### Benchmarks
- **First completion**: ~120ms (network + parse)
- **Cached completion**: ~14ms (8x faster)
- **Daemon startup**: ~100ms
- **Loader script**: Exits immediately after spawning daemon
## Troubleshooting
### Daemon Not Starting
Check if the daemon is running:
```bash
ps aux | grep "loader.ts.*daemon"
cat /tmp/scripts-daemon.port
curl http://127.0.0.1:$(cat /tmp/scripts-daemon.port)/health
```
Restart the daemon:
```bash
pkill -f "loader.ts.*daemon"
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet loader.ts)"
```
### Completions Not Working
1. Verify daemon is running (see above)
2. Check completion function is exported:
```bash
deno eval 'const m = await import("file:///path/to/script.ts"); console.log(typeof m.getCompletions)'
```
3. Test completion function directly:
```bash
deno eval 'const m = await import("file:///path/to/script.ts"); console.log(await m.getCompletions({args: [], current: "--", position: 0}))'
```
### Slow Completions
- Check network latency to Git server
- Verify caching is working (second request should be faster)
- Consider increasing cache TTL in daemon code
- Avoid expensive operations in completion functions
### Scripts Not Found
```bash
# Check script exists in repository
scripts
# Verify URL is accessible
curl -I https://git.kkarolis.lt/Karolis/scripts/raw/branch/main/script-name.ts
# Try with explicit ref
scripts script-name@main
```
## Advanced Usage
### Multiple Script Repositories
You can have multiple loader instances with different configurations:
```bash
# Personal scripts
export LOADER_REF=main
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet https://git.example.com/me/scripts/raw/branch/main/loader.ts)"
alias myscripts='scripts'
# Work scripts
export LOADER_REF=production
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet https://git.company.com/team/scripts/raw/branch/production/loader.ts)"
alias workscripts='scripts'
```
### Development Workflow
```bash
# Use local loader for testing
cd ~/scripts
export LOADER_REF=main
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet ./loader.ts)"
# Test your script
scripts my-new-script --help
# Push and use remote version
git push
export LOADER_REF=main
eval "$(deno run --allow-net --allow-read --allow-env --allow-run --quiet https://git.kkarolis.lt/Karolis/scripts/raw/branch/main/loader.ts)"
scripts my-new-script@main --help
```
## Files in This Repository
- **`loader.ts`** - Main loader script with daemon
- **`completions.d.ts`** - TypeScript definitions for completion functions
- **`COMPLETIONS.md`** - Comprehensive guide for script developers
- **`configure_ssh_key.ts`** - Example script with advanced completions
- **`README.md`** - This file
## Requirements
- [Deno](https://deno.land/) 1.30+ installed
- Bash or Zsh shell
- Network access to Git server
- `curl` for health checks (in completion functions)
## License
MIT

149
completions.d.ts vendored Normal file
View file

@ -0,0 +1,149 @@
/**
* Type definitions for script completion functions
*
* Scripts can export a `getCompletions` function to provide intelligent
* shell completion suggestions for their arguments.
*
* @example
* ```typescript
* export function getCompletions(ctx: CompletionContext): string[] {
* const { current, args } = ctx;
*
* // Suggest values for --key flag
* if (current.startsWith("--key=")) {
* return ["--key=~/.ssh/id_rsa", "--key=~/.ssh/id_ed25519"];
* }
*
* // Check what's already provided
* const hasForce = args.includes("--force");
*
* // Return available flags
* return ["--force", "--dry-run", "--help"].filter(
* flag => flag !== "--force" || !hasForce
* );
* }
* ```
*/
/**
* Context provided to the completion function
*/
export interface CompletionContext {
/**
* All arguments provided so far (excluding the script name)
*
* @example
* If user typed: `scripts my-script --flag1 value1 --flag2`
* Then args would be: ["--flag1", "value1", "--flag2"]
*/
args: string[];
/**
* The current word being completed
*
* @example
* - User typed `--k` and pressed TAB current = "--k"
* - User typed `--key=` and pressed TAB current = "--key="
* - User typed `--port=22` and pressed TAB current = "--port=22"
*/
current: string;
/**
* Position of the current word in the args array (0-indexed)
*
* @example
* If user typed: `scripts my-script arg1 arg2 <TAB>`
* Then position would be 2 (0=arg1, 1=arg2, 2=current)
*/
position: number;
}
/**
* Completion function that scripts should export
*
* This function is called by the loader daemon when the user requests
* shell completions. It should return an array of completion suggestions
* based on the provided context.
*
* Can be synchronous or asynchronous (return Promise<string[]>).
*
* @param ctx - Completion context with current state
* @returns Array of completion suggestions (can be empty), or Promise resolving to array
*
* @remarks
* - Return empty array if no completions are available
* - Suggestions are filtered by the shell, so return all matches
* - Can include partial matches (e.g., "--key=" to prompt for value)
* - Keep execution fast (< 50ms recommended) for responsive UX
* - Use Deno APIs to discover filesystem, environment, etc.
* - Async functions enable API calls, but keep them fast
*
* @example Simple flag completions
* ```typescript
* export function getCompletions(ctx: CompletionContext): string[] {
* return ["--verbose", "--quiet", "--help"];
* }
* ```
*
* @example Async completions with API call
* ```typescript
* export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
* const { current } = ctx;
*
* if (current.startsWith("--branch=")) {
* try {
* const response = await fetch("https://api.github.com/repos/user/repo/branches");
* const branches = await response.json();
* return branches.map(b => `--branch=${b.name}`);
* } catch {
* return ["--branch=main"];
* }
* }
*
* return ["--branch=", "--help"];
* }
* ```
*
* @example Context-aware completions
* ```typescript
* export function getCompletions(ctx: CompletionContext): string[] {
* const { current, args } = ctx;
*
* // Complete values for flags
* if (current.startsWith("--output=")) {
* return ["--output=json", "--output=yaml", "--output=text"];
* }
*
* // Avoid duplicate flags
* const usedFlags = new Set(args.map(a => a.split("=")[0]));
*
* const allFlags = ["--output=", "--verbose", "--format="];
* return allFlags.filter(f => !usedFlags.has(f.replace("=", "")));
* }
* ```
*
* @example Dynamic completions with filesystem
* ```typescript
* export function getCompletions(ctx: CompletionContext): string[] {
* const { current } = ctx;
*
* if (current.startsWith("--config=")) {
* try {
* // Find config files in current directory
* const files: string[] = [];
* for (const entry of Deno.readDirSync(".")) {
* if (entry.isFile && entry.name.endsWith(".json")) {
* files.push(`--config=${entry.name}`);
* }
* }
* return files;
* } catch {
* return ["--config=config.json"];
* }
* }
*
* return ["--config=", "--help"];
* }
* ```
*/
export type CompletionFunction = (ctx: CompletionContext) => string[] | Promise<string[]>;

View file

@ -26,9 +26,117 @@
* from available keys in ~/.ssh or ssh-agent.
*/
import { parseArgs } from "@std/cli/parse-args";
import { expandGlob } from "@std/fs/expand-glob";
import { join } from "@std/path";
// Export completion function for loader
import type { CompletionContext } from "./completions.d.ts";
export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
const { current, args } = ctx;
// Helper to find SSH keys in ~/.ssh/
const findSSHKeys = async (): Promise<string[]> => {
try {
const home = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || "";
if (!home) return [];
const sshDir = `${home}/.ssh`;
const keys: string[] = [];
try {
for await (const entry of Deno.readDir(sshDir)) {
if (entry.isFile && !entry.name.endsWith(".pub") &&
(entry.name.startsWith("id_") || entry.name.includes("key"))) {
keys.push(`--key=~/.ssh/${entry.name}`);
}
}
} catch {
// Fallback to common names if can't read directory
return ["--key=~/.ssh/id_rsa", "--key=~/.ssh/id_ed25519", "--key=~/.ssh/id_ecdsa"];
}
return keys.length > 0 ? keys : ["--key=~/.ssh/id_rsa", "--key=~/.ssh/id_ed25519"];
} catch {
return ["--key=~/.ssh/id_rsa", "--key=~/.ssh/id_ed25519"];
}
};
// If completing a value after --key=
if (current.startsWith("--key=")) {
const prefix = "--key=";
const value = current.slice(prefix.length);
// If value is empty or starts with ~, suggest keys
if (!value || value.startsWith("~")) {
return await findSSHKeys();
}
// Otherwise suggest path completion hint
return ["--key=<path-to-private-key>"];
}
// If completing --alias= value
if (current.startsWith("--alias=")) {
// Suggest format based on first positional arg if present
const target = args.find(a => !a.startsWith("-"));
if (target && target.includes("@")) {
const [user, host] = target.split("@");
const hostPart = host.split(":")[0];
return [`--alias=${user}-${hostPart.replace(/\./g, "-")}`];
}
return ["--alias=<host-alias>"];
}
// If completing --port= value
if (current.startsWith("--port=")) {
return ["--port=22", "--port=2222", "--port=2200"];
}
// If no flags yet and no target, suggest target format
const hasTarget = args.some(a => !a.startsWith("-") && a.includes("@"));
if (!hasTarget && !current.startsWith("-")) {
return ["user@hostname", "user@hostname:port"];
}
// Check if certain flags already provided to avoid duplicates
const hasGenerate = args.includes("--generate");
const hasKey = args.some(a => a.startsWith("--key"));
const hasAlias = args.some(a => a.startsWith("--alias"));
const hasPort = args.some(a => a.startsWith("--port"));
const hasForce = args.includes("--force");
const hasDryRun = args.includes("--dry-run");
const options: string[] = [];
// Only suggest --key if --generate not present
if (!hasGenerate && !hasKey) {
options.push("--key=");
}
if (!hasGenerate && !hasKey) {
options.push("--generate");
}
if (!hasAlias) {
options.push("--alias=");
}
if (!hasPort) {
options.push("--port=");
}
if (!hasForce) {
options.push("--force");
}
if (!hasDryRun) {
options.push("--dry-run");
}
options.push("--help");
return options;
}
import { parseArgs } from "jsr:@std/cli@^1.0.0/parse-args";
import { expandGlob } from "jsr:@std/fs@^1.0.0/expand-glob";
import { join } from "jsr:@std/path@^1.0.0";
interface ParsedTarget {
user: string;

View file

@ -6,5 +6,10 @@
},
"tasks": {
"configure": "deno run --allow-read --allow-write --allow-run --allow-env configure_ssh_key.ts"
},
"lint": {
"rules": {
"exclude": ["no-external-import"]
}
}
}

3
deno.lock generated
View file

@ -28,6 +28,9 @@
]
}
},
"remote": {
"https://git.kkarolis.lt/Karolis/scripts/raw/branch/main/configure_ssh_key.ts": "43e067a65c8be382581ca70d37d384679c88b6973ca5052ed3cc323015c7cfed"
},
"workspace": {
"dependencies": [
"jsr:@std/cli@1",

411
loader.ts Normal file
View 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
# ----------------------------------------------------------------------
`);