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
447
COMPLETIONS.md
Normal file
447
COMPLETIONS.md
Normal 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
322
README.md
Normal 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
149
completions.d.ts
vendored
Normal 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[]>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
3
deno.lock
generated
|
|
@ -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
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