Files
fluxer/scripts/remote.mjs
T

371 lines
12 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {existsSync, mkdirSync, readFileSync} from 'node:fs';
import {homedir} from 'node:os';
import path from 'node:path';
import process from 'node:process';
const repoRoot = path.resolve(new URL('..', import.meta.url).pathname);
const defaultConfigPath = path.join(repoRoot, '.fluxer', 'remote-hosts.json');
const defaultControlDir = path.join(repoRoot, '.fluxer', 'remote-ssh');
function usage(exitCode = 0) {
console.log(`Usage:
pnpm remote list
pnpm remote doctor <host>
pnpm remote bootstrap <host> [--branch <branch>]
pnpm remote pull <host> [--branch <branch>]
pnpm remote run <host> -- <command>
pnpm remote apply-diff <host> [-- <path>...]
pnpm remote tunnel start|status|stop <host>
pnpm remote macos-setup [--pubkey ~/.ssh/id_ed25519.pub]
Global options:
--config <path> Host config path. Defaults to .fluxer/remote-hosts.json.
--verbose Print spawned ssh commands.
`);
process.exit(exitCode);
}
function fail(message, exitCode = 1) {
console.error(message);
process.exit(exitCode);
}
function parseGlobalArgs(argv) {
const options = {configPath: defaultConfigPath, verbose: false};
const rest = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--config') {
options.configPath = path.resolve(argv[++i] ?? fail('--config needs a path'));
} else if (arg === '--verbose') {
options.verbose = true;
} else if (arg === '--help' || arg === '-h') {
usage(0);
} else {
rest.push(arg);
}
}
return {options, args: rest};
}
function readJson(filePath) {
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch (error) {
fail(`Failed to read ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function loadConfig(configPath) {
if (!existsSync(configPath)) {
fail(
`Remote host config not found: ${configPath}\n` +
`Copy scripts/remote/hosts.example.json to .fluxer/remote-hosts.json and edit it.`,
);
}
const config = readJson(configPath);
if (!config || typeof config !== 'object' || !config.hosts || typeof config.hosts !== 'object') {
fail(`Invalid remote host config: ${configPath}`);
}
return config;
}
function getHost(config, name) {
const host = config.hosts[name];
if (!host || typeof host !== 'object') {
const known = Object.keys(config.hosts).sort().join(', ') || '(none)';
fail(`Unknown remote host "${name}". Known hosts: ${known}`);
}
if (typeof host.host !== 'string' || host.host.length === 0) {
fail(`Host "${name}" is missing "host"`);
}
if (host.platform !== 'windows' && host.platform !== 'macos' && host.platform !== 'linux') {
fail(`Host "${name}" needs platform "windows", "macos", or "linux"`);
}
return host;
}
function expandHome(value) {
if (typeof value !== 'string') return value;
if (value === '~') return homedir();
if (value.startsWith('~/')) return path.join(homedir(), value.slice(2));
return value;
}
function targetFor(host) {
return host.user ? `${host.user}@${host.host}` : host.host;
}
function controlPathFor(name, host) {
if (host.controlPath) return path.resolve(expandHome(host.controlPath));
mkdirSync(defaultControlDir, {recursive: true, mode: 0o700});
return path.join(defaultControlDir, `${name.replace(/[^A-Za-z0-9_.-]/g, '_')}.ctl`);
}
function sshBaseArgs(name, host, {control = true} = {}) {
const args = [];
if (host.port) args.push('-p', String(host.port));
if (host.identityFile) args.push('-i', path.resolve(expandHome(host.identityFile)));
args.push('-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=3', '-o', 'StrictHostKeyChecking=accept-new');
if (control) {
args.push('-S', controlPathFor(name, host), '-o', 'ControlMaster=auto', '-o', 'ControlPersist=30m');
}
return args;
}
function forwardArgs(host) {
const forwards = Array.isArray(host.forwards) ? host.forwards : [];
const args = [];
for (const forward of forwards) {
if (typeof forward === 'string') {
args.push('-L', forward);
} else if (forward && typeof forward === 'object' && forward.local && forward.remote) {
args.push('-L', `${forward.local}:${forward.remote}`);
} else {
fail(`Invalid forward entry for ${host.host}`);
}
}
return args;
}
function shQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function psQuote(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function remoteCommand(host, command) {
const repo = host.repo;
if (host.platform === 'windows') {
const body = `${repo ? `Set-Location -LiteralPath ${psQuote(repo)}; ` : ''}${command}`;
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${Buffer.from(body, 'utf16le').toString('base64')}`;
}
const body = `${repo ? `cd ${shQuote(repo)} && ` : ''}${command}`;
return `bash -lc ${shQuote(body)}`;
}
function spawnChecked(command, args, {verbose = false, input} = {}) {
if (verbose) console.error(`$ ${command} ${args.join(' ')}`);
const result = spawnSync(command, args, {
cwd: repoRoot,
input,
stdio: input === undefined ? 'inherit' : ['pipe', 'inherit', 'inherit'],
});
if (result.error) fail(`${command} failed to start: ${result.error.message}`);
if (result.status !== 0) process.exit(result.status ?? 1);
}
function spawnCapture(command, args) {
const result = spawnSync(command, args, {cwd: repoRoot, encoding: 'utf8'});
if (result.error || result.status !== 0) return null;
return result.stdout.trim();
}
function spawnCaptureRaw(command, args) {
const result = spawnSync(command, args, {cwd: repoRoot, encoding: 'utf8'});
if (result.error || result.status !== 0) return null;
return result.stdout;
}
function ssh(name, host, command, options = {}) {
spawnChecked('ssh', [...sshBaseArgs(name, host), targetFor(host), remoteCommand(host, command)], options);
}
function sshWithInput(name, host, command, input, options = {}) {
spawnChecked('ssh', [...sshBaseArgs(name, host), targetFor(host), remoteCommand(host, command)], {
...options,
input,
});
}
function currentBranch() {
return spawnCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD']) || 'main';
}
function originUrl() {
return spawnCapture('git', ['config', '--get', 'remote.origin.url']) || '';
}
function splitAfterDoubleDash(args) {
const index = args.indexOf('--');
if (index === -1) return {head: args, tail: []};
return {head: args.slice(0, index), tail: args.slice(index + 1)};
}
function optionValue(args, name, fallback) {
const index = args.indexOf(name);
if (index === -1) return fallback;
const value = args[index + 1];
if (!value) fail(`${name} needs a value`);
return value;
}
function cloneOrUpdateCommand(host, branch) {
const repo = host.repo;
const repoUrl = host.repoUrl || originUrl();
if (!repo) fail('bootstrap/pull requires host.repo in the config');
if (!repoUrl) fail('bootstrap requires host.repoUrl or a local git remote.origin.url');
if (host.platform === 'windows') {
return [
`$repo = ${psQuote(repo)}`,
`$repoUrl = ${psQuote(repoUrl)}`,
`$branch = ${psQuote(branch)}`,
`if (!(Test-Path -LiteralPath (Join-Path $repo '.git'))) {`,
` New-Item -ItemType Directory -Force -Path (Split-Path -Parent $repo) | Out-Null`,
` git clone $repoUrl $repo`,
`}`,
`Set-Location -LiteralPath $repo`,
`git fetch --all --prune`,
`git checkout $branch`,
`git pull --ff-only`,
`corepack enable`,
`pnpm install`,
].join('; ');
}
return [
`repo=${shQuote(repo)}`,
`repo_url=${shQuote(repoUrl)}`,
`branch=${shQuote(branch)}`,
`if [ ! -d "$repo/.git" ]; then mkdir -p "$(dirname "$repo")"; git clone "$repo_url" "$repo"; fi`,
`cd "$repo"`,
`git fetch --all --prune`,
`git checkout "$branch"`,
`git pull --ff-only`,
`corepack enable`,
`pnpm install`,
].join(' && ');
}
function pullCommand(host, branch) {
const repo = host.repo;
if (!repo) fail('pull requires host.repo in the config');
if (host.platform === 'windows') {
return [
`Set-Location -LiteralPath ${psQuote(repo)}`,
`git fetch --all --prune`,
`git checkout ${psQuote(branch)}`,
`git pull --ff-only`,
].join('; ');
}
return [
`cd ${shQuote(repo)}`,
`git fetch --all --prune`,
`git checkout ${shQuote(branch)}`,
`git pull --ff-only`,
].join(' && ');
}
function macosSetup(args) {
const pubkeyPath = path.resolve(expandHome(optionValue(args, '--pubkey', '~/.ssh/id_ed25519.pub')));
const pubkey = existsSync(pubkeyPath) ? readFileSync(pubkeyPath, 'utf8').trim() : '<paste-your-public-ssh-key-here>';
console.log(`# Run this on the macOS machine while Tailscale is connected.
sudo systemsetup -setremotelogin on
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\\n' ${shQuote(pubkey)} >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
tailscale status
tailscale ip -4
sudo lsof -nP -iTCP:22 -sTCP:LISTEN
# Optional, only if you intentionally use Tailscale SSH ACLs instead of OpenSSH keys:
# sudo tailscale up --ssh
`);
}
const {options, args} = parseGlobalArgs(process.argv.slice(2));
const command = args[0];
if (!command) usage(1);
if (command === 'macos-setup') {
macosSetup(args.slice(1));
process.exit(0);
}
const config = loadConfig(options.configPath);
switch (command) {
case 'list': {
for (const [name, host] of Object.entries(config.hosts)) {
console.log(`${name}\t${host.platform}\t${targetFor(host)}\t${host.repo ?? ''}`);
}
break;
}
case 'doctor': {
const name = args[1] ?? fail('doctor needs a host');
const host = getHost(config, name);
const remote =
host.platform === 'windows'
? '$PSVersionTable.PSVersion.ToString(); git --version; node --version; pnpm --version; rustc --version; cargo --version; Get-ComputerInfo | Select-Object -ExpandProperty OsName'
: 'uname -a; git --version; node --version; pnpm --version; rustc --version; cargo --version';
ssh(name, host, remote, options);
break;
}
case 'bootstrap': {
const name = args[1] ?? fail('bootstrap needs a host');
const host = getHost(config, name);
ssh(name, host, cloneOrUpdateCommand(host, optionValue(args, '--branch', currentBranch())), options);
break;
}
case 'pull': {
const name = args[1] ?? fail('pull needs a host');
const host = getHost(config, name);
ssh(name, host, pullCommand(host, optionValue(args, '--branch', currentBranch())), options);
break;
}
case 'run': {
const {head, tail} = splitAfterDoubleDash(args.slice(1));
const name = head[0] ?? fail('run needs a host');
if (tail.length === 0) fail('run needs -- <command>');
ssh(name, getHost(config, name), tail.join(' '), options);
break;
}
case 'apply-diff': {
const {head, tail} = splitAfterDoubleDash(args.slice(1));
const name = head[0] ?? fail('apply-diff needs a host');
const host = getHost(config, name);
const diff = spawnCaptureRaw('git', ['diff', '--binary', '--', ...tail]);
if (!diff) {
console.log('No local diff to apply.');
break;
}
sshWithInput(name, host, 'git apply --whitespace=nowarn -', diff, options);
break;
}
case 'tunnel': {
const action = args[1] ?? fail('tunnel needs start|status|stop');
const name = args[2] ?? fail('tunnel needs a host');
const host = getHost(config, name);
if (action === 'start') {
spawnChecked(
'ssh',
[
...sshBaseArgs(name, host),
...forwardArgs(host),
'-fN',
'-M',
'-o',
'ExitOnForwardFailure=yes',
targetFor(host),
],
options,
);
} else if (action === 'status') {
spawnChecked('ssh', [...sshBaseArgs(name, host), '-O', 'check', targetFor(host)], options);
} else if (action === 'stop') {
spawnChecked('ssh', [...sshBaseArgs(name, host), '-O', 'exit', targetFor(host)], options);
} else {
fail('tunnel action must be start, status, or stop');
}
break;
}
default:
usage(1);
}