Move scripts to scripts/ and update loader paths; use remote import_map with --no-config
This commit is contained in:
parent
4322bdeb1f
commit
7f69b3e59c
4 changed files with 662 additions and 7 deletions
6
configure_ssh_key.ts
Executable file → Normal file
6
configure_ssh_key.ts
Executable file → Normal 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
10
import_map.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
12
loader.ts
12
loader.ts
|
|
@ -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
641
scripts/configure_ssh_key.ts
Executable 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue