Move scripts to scripts/ and update loader paths; use remote import_map with --no-config

This commit is contained in:
Karolis2011 2025-11-29 23:48:28 +02:00
parent 4322bdeb1f
commit 7f69b3e59c
4 changed files with 662 additions and 7 deletions

6
configure_ssh_key.ts Executable file → Normal file
View file

@ -27,7 +27,11 @@
*/
// Export completion function for loader
import type { CompletionContext } from "./completions.d.ts";
interface CompletionContext {
args: string[];
current: string;
position: number;
}
export async function getCompletions(ctx: CompletionContext): Promise<string[]> {
const { current, args } = ctx;

10
import_map.json Normal file
View file

@ -0,0 +1,10 @@
{
"imports": {
"@std/cli": "jsr:@std/cli@^1.0.0",
"@std/cli/": "jsr:@std/cli@^1.0.0/",
"@std/fs": "jsr:@std/fs@^1.0.0",
"@std/fs/": "jsr:@std/fs@^1.0.0/",
"@std/path": "jsr:@std/path@^1.0.0",
"@std/path/": "jsr:@std/path@^1.0.0/"
}
}

View file

@ -355,9 +355,9 @@ scripts() {
local url
if [[ "$ref" == v* ]]; then
url="${BASE_RAW}/tag/$ref/$name.ts"
url="${BASE_RAW}/tag/$ref/scripts/$name.ts"
else
url="${BASE_RAW}/branch/$ref/$name.ts"
url="${BASE_RAW}/branch/$ref/scripts/$name.ts"
fi
if ! curl -fsI "$url" >/dev/null 2>&1; then
@ -365,14 +365,14 @@ scripts() {
return 127;
fi
local config_url
local import_map_url
if [[ "$ref" == v* ]]; then
config_url="${BASE_RAW}/tag/$ref/deno.json"
import_map_url="${BASE_RAW}/tag/$ref/import_map.json"
else
config_url="${BASE_RAW}/branch/$ref/deno.json"
import_map_url="${BASE_RAW}/branch/$ref/import_map.json"
fi
deno run --allow-all --config="$config_url" "$url" "$@";
deno run --allow-all --no-config --import-map="$import_map_url" "$url" "$@";
}
# --- Autocompletion ---------------------------------------------------

641
scripts/configure_ssh_key.ts Executable file
View file

@ -0,0 +1,641 @@
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env
/**
* configure_ssh_key.ts
* Add or update an SSH config entry for a specific host/user using a specified key.
* Can optionally generate the key if it does not exist.
*
* Usage:
* ./configure_ssh_key.ts alice@example.com --key ~/.ssh/id_alice_example --alias work-example
* ./configure_ssh_key.ts alice@example.com --generate --alias work-example
* ./configure_ssh_key.ts alice@example.com --alias work-example
* ./configure_ssh_key.ts alice@example.com:2222
*
* Required positional target:
* user@host[:port]
*
* Options:
* --key /path/to/key Path to private key
* --generate Generate a new ed25519 key if missing
* --alias HOST_ALIAS Host alias (defaults to USER-HOST simplified)
* --port PORT Override / set SSH port
* --force Overwrite existing config block for this alias
* --dry-run Show intended changes without writing
* --help Show help
*
* If neither --key nor --generate is specified, you'll be prompted to select
* from available keys in ~/.ssh or ssh-agent.
*/
// Export completion function for loader
interface CompletionContext {
args: string[];
current: string;
position: number;
}
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;
host: string;
port?: number;
}
interface Options {
keyPath?: string;
alias?: string;
generate: boolean;
port?: number;
force: boolean;
dryRun: boolean;
isAgentKey?: boolean; // Track if key only exists in ssh-agent
}
interface AgentKey {
keyType: string;
publicKey: string;
comment: string;
displayName: string;
}
const HOME = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || "";
const SSH_DIR = join(HOME, ".ssh");
const CONFIG_FILE = join(SSH_DIR, "config");
function printHelp() {
console.log(`
configure_ssh_key.ts - SSH Config Manager
Usage:
./configure_ssh_key.ts user@host[:port] [options]
Examples:
./configure_ssh_key.ts alice@example.com --key ~/.ssh/id_alice_example --alias work-example
./configure_ssh_key.ts alice@example.com --generate --alias work-example
./configure_ssh_key.ts alice@example.com --alias work-example
./configure_ssh_key.ts alice@example.com:2222
Options:
--key PATH Path to private key
--generate Generate a new ed25519 key if missing
--alias NAME Host alias (defaults to user-host)
--port PORT Override SSH port
--force Overwrite existing config block
--dry-run Preview changes without writing
--help, -h Show this help
Note: If neither --key nor --generate is specified, you'll be prompted to
select from available keys in ~/.ssh or ssh-agent.
`);
}
function parseTarget(target: string): ParsedTarget {
// user@host:port
let match = target.match(/^([^@]+)@([^:]+):(\d+)$/);
if (match) {
return { user: match[1], host: match[2], port: parseInt(match[3]) };
}
// user@host
match = target.match(/^([^@]+)@([^:]+)$/);
if (match) {
return { user: match[1], host: match[2] };
}
// host:port
match = target.match(/^([^:]+):(\d+)$/);
if (match) {
throw new Error("Target must include user (format: user@host[:port])");
}
throw new Error("Invalid target format. Use: user@host[:port]");
}
async function findPrivateKeys(): Promise<string[]> {
const keys: string[] = [];
try {
for await (const entry of expandGlob(`${SSH_DIR}/id_*`, { includeDirs: false })) {
if (!entry.name.endsWith(".pub")) {
keys.push(entry.path);
}
}
for await (const entry of expandGlob(`${SSH_DIR}/*.pem`, { includeDirs: false })) {
keys.push(entry.path);
}
} catch {
// Directory might not exist yet
}
return keys.sort();
}
async function listAgentKeys(): Promise<AgentKey[]> {
const cmd = new Deno.Command("ssh-add", {
args: ["-L"],
stdout: "piped",
stderr: "piped",
});
const { success, stdout } = await cmd.output();
if (!success) {
return [];
}
const output = new TextDecoder().decode(stdout);
const lines = output.trim().split("\n").filter(line => line.length > 0);
return lines.map(line => {
const parts = line.split(" ");
const keyType = parts[0] || "";
const publicKey = parts[1] || "";
const comment = parts.slice(2).join(" ") || "(no comment)";
const displayName = comment.replace(/[/: ]/g, "_").toLowerCase();
return { keyType, publicKey, comment, displayName };
});
}
async function generateKey(keyPath: string, user: string, host: string) {
try {
await Deno.stat(keyPath);
console.log(`Key already exists: ${keyPath} (skipping generation)`);
return;
} catch {
// Key doesn't exist, generate it
}
console.log(`Generating ed25519 key: ${keyPath}`);
const cmd = new Deno.Command("ssh-keygen", {
args: [
"-t", "ed25519",
"-a", "100",
"-f", keyPath,
"-C", `${user}@${host}`,
"-N", "",
],
});
const { success } = await cmd.output();
if (!success) {
throw new Error("Failed to generate SSH key");
}
}
async function ensurePermissions(keyPath: string) {
// Verify this is actually a private key file (not .pub)
if (keyPath.endsWith(".pub")) {
throw new Error("Key path should point to private key, not .pub file");
}
// Set private key permissions
try {
await Deno.chmod(keyPath, 0o600);
} catch (e) {
// If file doesn't exist yet (e.g., agent key), that's ok
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
}
// Set public key permissions if it exists
const pubKeyPath = `${keyPath}.pub`;
try {
await Deno.stat(pubKeyPath);
await Deno.chmod(pubKeyPath, 0o644);
} catch {
// .pub file doesn't exist, that's ok
}
}
function generateConfigBlock(
alias: string,
host: string,
user: string,
keyPath: string,
port?: number,
): string {
const lines = [
`Host ${alias}`,
` HostName ${host}`,
` User ${user}`,
];
if (port) {
lines.push(` Port ${port}`);
}
lines.push(
` IdentityFile ${keyPath}`,
` IdentitiesOnly yes`,
` PreferredAuthentications publickey`,
);
return lines.join("\n");
}
async function readConfig(): Promise<string> {
try {
return await Deno.readTextFile(CONFIG_FILE);
} catch {
return "";
}
}
function removeHostBlock(config: string, alias: string): string {
const lines = config.split("\n");
const result: string[] = [];
let skipping = false;
for (const line of lines) {
if (line.match(/^Host\s+/)) {
const hostMatch = line.match(/^Host\s+(\S+)/);
if (hostMatch && hostMatch[1] === alias) {
skipping = true;
continue;
} else {
skipping = false;
}
}
if (!skipping) {
result.push(line);
}
}
return result.join("\n");
}
function hasHostBlock(config: string, alias: string): boolean {
const pattern = new RegExp(`^Host\\s+${alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`, "m");
return pattern.test(config);
}
async function backupConfig() {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T").join("_").slice(0, 19);
const backupPath = `${CONFIG_FILE}.bak.${timestamp}`;
await Deno.copyFile(CONFIG_FILE, backupPath);
} catch {
// Backup failed, continue anyway
}
}
function expandTilde(path: string): string {
if (path.startsWith("~/")) {
return join(HOME, path.slice(2));
}
return path;
}
function normalizeKeyPath(path: string): string {
// Remove .pub extension if present - we always work with private key paths
return path.endsWith(".pub") ? path.slice(0, -4) : path;
}
function simplifyForAlias(str: string): string {
return str.toLowerCase().replace(/[^a-z0-9]/g, "-");
}
// Utility: Check for mutually exclusive options
function checkExclusiveOptions(opts: boolean[]): boolean {
return opts.filter(Boolean).length <= 1;
}
// Utility: Central error handler
function handleError(e: unknown) {
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
Deno.exit(1);
}
// Utility: Unified key selection
async function selectKeyUnified(options: Options): Promise<{ keyPath: string, isAgentKey: boolean }> {
if (options.keyPath) return { keyPath: options.keyPath, isAgentKey: false };
// Interactive combined selection (file keys + agent keys)
const fileKeys = await findPrivateKeys();
const agentKeys = await listAgentKeys();
// ...existing code for combinedKeys...
const fileKeyInfos: Array<{ path: string, pub: string | null }> = [];
for (const path of fileKeys) {
let pub: string | null = null;
try {
pub = await Deno.readTextFile(path + ".pub");
} catch {
pub = null;
}
fileKeyInfos.push({ path, pub });
}
async function agentKeyFilename(agentKey: AgentKey) {
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(agentKey.publicKey))))
.map(b => b.toString(16).padStart(2, "0")).join("");
return `id_agent_${hash}`;
}
const combinedKeys: Array<{ type: "file" | "agent", path?: string, agentKey?: AgentKey, display: string, linkedFile?: string }> = [];
for (const fileInfo of fileKeyInfos) {
combinedKeys.push({ type: "file", path: fileInfo.path, display: `[file] ${fileInfo.path}` });
}
for (const agentKey of agentKeys) {
let linkedFile: string | undefined = undefined;
for (const fileInfo of fileKeyInfos) {
if (fileInfo.pub && fileInfo.pub.includes(agentKey.publicKey)) {
linkedFile = fileInfo.path;
break;
}
}
combinedKeys.push({
type: "agent",
agentKey,
display: `[agent] ${agentKey.comment} (${agentKey.keyType})${linkedFile ? ` [linked: ${linkedFile}]` : ''}`,
linkedFile
});
}
if (combinedKeys.length === 0) {
throw new Error("No candidate private keys found in ~/.ssh or ssh-agent");
}
console.log("\nAvailable keys:");
combinedKeys.forEach((key, i) => {
console.log(` [${i}] ${key.display}`);
});
let selectedIdx: number | undefined = undefined;
// Detect non-interactive environment
const isInteractive = typeof Deno.stdin.setRaw === "function" && Deno.stdin.isTerminal();
if (!isInteractive) {
// If keyPath is not provided, fail in non-interactive mode
if (!options.keyPath) {
console.error("Error: Interactive key selection required, but running in non-interactive mode. Use --key to specify a key.");
Deno.exit(1);
}
// If keyPath is provided, select it automatically
selectedIdx = combinedKeys.findIndex(k => k.type === "file" && k.path === options.keyPath);
if (selectedIdx === -1) {
console.error(`Error: Specified key not found: ${options.keyPath}`);
Deno.exit(1);
}
} else {
while (selectedIdx === undefined) {
const input = prompt("\nSelect key number (or q to quit):");
if (input === null || input.toLowerCase() === "q") {
console.error("Error: No key selected. Exiting.");
Deno.exit(1);
}
const num = parseInt(input ?? "");
if (!isNaN(num) && num >= 0 && num < combinedKeys.length) {
selectedIdx = num;
} else {
console.error("Invalid selection. Try again.");
}
}
}
const selected = combinedKeys[selectedIdx];
if (selected.type === "file" && selected.path) {
return { keyPath: selected.path, isAgentKey: false };
} else if (selected.type === "agent" && selected.agentKey) {
if (selected.linkedFile) {
return { keyPath: selected.linkedFile, isAgentKey: false };
} else {
const filename = await agentKeyFilename(selected.agentKey);
const pubKeyPath = join(SSH_DIR, `${filename}.pub`);
let pubKeyExists = false;
try {
await Deno.stat(pubKeyPath);
pubKeyExists = true;
} catch {
// .pub file does not exist
}
if (!pubKeyExists) {
const pubKeyContent = `${selected.agentKey.keyType} ${selected.agentKey.publicKey} ${selected.agentKey.comment}\n`;
await Deno.writeTextFile(pubKeyPath, pubKeyContent);
await Deno.chmod(pubKeyPath, 0o644);
console.log(`\n✅ Saved public key to: ${pubKeyPath}`);
}
// Do not create a dummy private key file for agent keys
return { keyPath: pubKeyPath, isAgentKey: true };
}
}
throw new Error("No key path selected or generated.");
}
async function main() {
try {
const args = parseArgs(Deno.args, {
string: ["key", "alias", "port"],
boolean: ["generate", "force", "dry-run", "help", "h"],
alias: { h: "help" },
"--": true,
});
if (args.help || args.h) {
printHelp();
Deno.exit(0);
}
const targetSpec = args._[0]?.toString();
if (!targetSpec) {
throw new Error("Missing required target (user@host[:port])\nRun with --help for usage information");
}
let target: ParsedTarget;
try {
target = parseTarget(targetSpec);
} catch (e) {
throw e;
}
if (args.port) {
target.port = parseInt(args.port);
}
const keyProvided = !!args.key;
const generate = args.generate || false;
if (!checkExclusiveOptions([keyProvided, generate])) {
throw new Error("Use only one of --key or --generate");
}
const options: Options = {
keyPath: args.key ? normalizeKeyPath(expandTilde(args.key)) : undefined,
alias: args.alias,
generate,
port: target.port,
force: args.force || false,
dryRun: args["dry-run"] || false,
};
if (!options.alias) {
options.alias = `${target.user}-${simplifyForAlias(target.host)}`;
}
// Unified key selection
const keySelection = await selectKeyUnified(options);
options.keyPath = keySelection.keyPath;
options.isAgentKey = keySelection.isAgentKey;
if (!options.keyPath) {
throw new Error("No key path selected or generated.");
}
await Deno.mkdir(SSH_DIR, { recursive: true });
if (options.generate) {
await generateKey(options.keyPath, target.user, target.host);
}
// Only verify file exists and set permissions for non-agent keys
if (!options.isAgentKey) {
try {
await Deno.stat(options.keyPath);
} catch {
throw new Error(`Private key not found at ${options.keyPath}`);
}
await ensurePermissions(options.keyPath);
}
const configBlock = generateConfigBlock(
options.alias!,
target.host,
target.user,
options.keyPath,
options.port,
);
let existingConfig = await readConfig();
if (hasHostBlock(existingConfig, options.alias!)) {
if (!options.force) {
throw new Error(`Host block for '${options.alias}' already exists. Use --force to replace.`);
}
console.log(`Replacing existing Host block for '${options.alias}'.`);
existingConfig = removeHostBlock(existingConfig, options.alias!);
}
if (options.dryRun) {
console.log("\n--- SSH Config Block ---\n");
console.log(configBlock);
console.log("\n(Dry run: no changes written)");
return;
}
await backupConfig();
const newConfig = existingConfig.trim().length > 0
? `${existingConfig.trim()}\n\n${configBlock}\n`
: `${configBlock}\n`;
await Deno.writeTextFile(CONFIG_FILE, newConfig);
console.log(`\n✅ SSH config updated: ${CONFIG_FILE}`);
console.log(`\nTo connect, use: ssh ${options.alias}`);
} catch (e) {
handleError(e);
}
}
// Run main function
if (import.meta.main) {
main();
}