feat: ESLint, shell completions, Docker, nfpm packaging, CI/CD

- ESLint with typescript-eslint + prettier (eslint.config.js)
- Shell completions for bash and fish (scripts/generate-completions.ts)
- Multi-stage Dockerfile for bastion (fedora:43 + dnsmasq + node)
- nfpm.yaml for RPM/DEB packaging with bun-compiled binary
- Build scripts: build-rpm.sh, build-bastion.sh, publish-rpm/deb.sh
- Gitea Actions CI/CD: lint, typecheck, test, build, publish

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-17 21:51:01 +00:00
parent 520af41a52
commit ed1df8a77c
22 changed files with 1885 additions and 75 deletions

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Build bastion container image and push to Gitea container registry
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env for GITEA_TOKEN
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Registry defaults to internal address (external proxy has body size limit)
REGISTRY="${GITEA_REGISTRY:-mysources.co.uk}"
IMAGE="lab-bastion"
VERSION=$(node -p "require('./package.json').version")
TAG="${1:-$VERSION}"
echo "==> Building bastion image (tag: $TAG)..."
podman build -t "$IMAGE:$TAG" -f stack/Dockerfile .
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
if [ -n "$GITEA_TOKEN" ]; then
echo "==> Logging in to $REGISTRY..."
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
# Ensure package is linked to the repository
if [ -f "$SCRIPT_DIR/link-package.sh" ]; then
source "$SCRIPT_DIR/link-package.sh"
link_package "container" "$IMAGE"
fi
else
echo "==> GITEA_TOKEN not set, skipping push."
fi
echo "==> Done!"
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"

47
bastion/scripts/build-rpm.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env if present
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Ensure tools are on PATH
export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH"
echo "==> Running unit tests..."
pnpm test:run
echo ""
echo "==> Building TypeScript..."
pnpm build
echo "==> Generating shell completions..."
pnpm completions:generate
echo "==> Bundling standalone binary..."
mkdir -p dist
rm -f dist/lab dist/lab-*.rpm dist/lab*.deb
bun build src/cli/src/index.ts --compile --outfile dist/lab
echo "==> Packaging RPM..."
nfpm pkg --packager rpm --target dist/
RPM_FILE=$(ls dist/lab-*.rpm 2>/dev/null | head -1)
echo "==> Built: $RPM_FILE"
echo " Size: $(du -h "$RPM_FILE" | cut -f1)"
rpm -qpi "$RPM_FILE"
echo ""
echo "==> Packaging DEB..."
rm -f dist/lab*.deb
nfpm pkg --packager deb --target dist/
DEB_FILE=$(ls dist/lab*.deb 2>/dev/null | head -1)
echo "==> Built: $DEB_FILE"
echo " Size: $(du -h "$DEB_FILE" | cut -f1)"

View File

@@ -0,0 +1,385 @@
#!/usr/bin/env tsx
/**
* generate-completions.ts -- auto-generates shell completions from the commander.js command tree.
*
* Usage:
* tsx scripts/generate-completions.ts # print generated files to stdout
* tsx scripts/generate-completions.ts --write # write completions/ files
* tsx scripts/generate-completions.ts --check # exit 0 if files match, 1 if stale
*
* Requires `pnpm build` to have run first (workspace packages must be compiled).
*/
import { Command, type Option, type Argument } from 'commander';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ============================================================
// Command tree extraction
// ============================================================
interface CmdInfo {
name: string;
description: string;
hidden: boolean;
options: OptInfo[];
args: ArgInfo[];
subcommands: CmdInfo[];
}
interface OptInfo {
short?: string;
long: string;
description: string;
takesValue: boolean;
choices?: string[];
negate: boolean;
}
interface ArgInfo {
name: string;
description: string;
required: boolean;
variadic: boolean;
choices?: string[];
}
function extractOption(opt: Option): OptInfo {
return {
short: (opt as unknown as Record<string, string>).short || undefined,
long: (opt as unknown as Record<string, string>).long,
description: opt.description,
takesValue: (opt as unknown as Record<string, boolean>).required || (opt as unknown as Record<string, boolean>).optional || false,
choices: (opt as unknown as Record<string, string[] | undefined>).argChoices || undefined,
negate: (opt as unknown as Record<string, boolean>).negate || false,
};
}
function extractArgument(arg: Argument): ArgInfo {
return {
name: (arg as unknown as Record<string, string>)._name ?? arg.name(),
description: arg.description,
required: (arg as unknown as Record<string, boolean>).required,
variadic: (arg as unknown as Record<string, boolean>).variadic,
choices: (arg as unknown as Record<string, string[] | undefined>)._choices || undefined,
};
}
function extractCommand(cmd: Command): CmdInfo {
const options = (cmd.options as Option[])
.filter((o) => {
const long = (o as unknown as Record<string, string>).long;
return long !== '--help' && long !== '--version';
})
.map(extractOption);
const args = ((cmd as unknown as Record<string, Argument[]>).registeredArguments ?? [])
.map(extractArgument);
const subcommands = (cmd.commands as Command[])
.filter((sub) => sub.name() !== 'help')
.map(extractCommand);
if ((cmd.commands as Command[]).some((sub) => sub.name() === 'help')) {
subcommands.push({
name: 'help',
description: 'display help for command',
hidden: false,
options: [],
args: [],
subcommands: [],
});
}
return {
name: cmd.name(),
description: cmd.description(),
hidden: (cmd as unknown as Record<string, boolean>)._hidden ?? false,
options,
args,
subcommands,
};
}
async function extractTree(): Promise<CmdInfo> {
const { createProgram } = await import('../src/cli/src/index.js') as { createProgram: () => Command };
const program = createProgram();
return extractCommand(program);
}
// ============================================================
// Utilities
// ============================================================
function esc(s: string): string {
return s.replace(/'/g, "\\'");
}
/** Collect all commands recursively with their full path. */
function collectCommands(cmd: CmdInfo, prefix: string[] = []): { path: string[]; cmd: CmdInfo }[] {
const result: { path: string[]; cmd: CmdInfo }[] = [];
for (const sub of cmd.subcommands) {
const fullPath = [...prefix, sub.name];
result.push({ path: fullPath, cmd: sub });
result.push(...collectCommands(sub, fullPath));
}
return result;
}
// ============================================================
// Fish completion generator
// ============================================================
function generateFish(root: CmdInfo): string {
const lines: string[] = [];
const emit = (s: string): void => { lines.push(s); };
const BIN = root.name;
emit(`# ${BIN} fish completions -- auto-generated by scripts/generate-completions.ts`);
emit('# DO NOT EDIT MANUALLY -- run: pnpm completions:generate');
emit('');
emit(`complete -c ${BIN} -e`);
emit(`complete -c ${BIN} -f`);
emit('');
// Global options
emit('# Global options');
emit(`complete -c ${BIN} -s v -l version -d 'Show version'`);
emit(`complete -c ${BIN} -s h -l help -d 'Show help'`);
emit('');
const allCmds = collectCommands(root);
// Helper function for fish: test if exactly the given subcommand chain is present
emit('# Helper: test if a subcommand chain is active');
emit(`function __${BIN}_using_cmd`);
emit(' set -l tokens (commandline -opc)');
emit(' set -l expected $argv');
emit(' set -l depth (count $expected)');
emit(' set -l found 0');
emit(' set -l i 1');
emit(' for tok in $tokens[2..]');
emit(' if string match -q -- "-*" $tok');
emit(' continue');
emit(' end');
emit(' set i (math $i + 1)');
emit(' set -l idx (math $i - 1)');
emit(' if test $idx -le $depth');
emit(' if test "$tok" != "$expected[$idx]"');
emit(' return 1');
emit(' end');
emit(' set found (math $found + 1)');
emit(' else');
emit(' return 1');
emit(' end');
emit(' end');
emit(' test $found -eq $depth');
emit('end');
emit('');
// Top-level commands
const topCmds = root.subcommands.filter((c) => !c.hidden);
emit('# Top-level commands');
for (const cmd of topCmds) {
emit(`complete -c ${BIN} -n "not __fish_seen_subcommand_from ${topCmds.map((c) => c.name).join(' ')}" -a ${cmd.name} -d '${esc(cmd.description)}'`);
}
emit('');
// Subcommands and options at each level
for (const { path, cmd } of allCmds) {
if (cmd.hidden) continue;
// If this command has subcommands, offer them
const visibleSubs = cmd.subcommands.filter((s) => !s.hidden);
if (visibleSubs.length > 0) {
const parentCondition = `__${BIN}_using_cmd ${path.join(' ')}`;
emit(`# ${path.join(' ')} subcommands`);
for (const sub of visibleSubs) {
emit(`complete -c ${BIN} -n "${parentCondition}" -a ${sub.name} -d '${esc(sub.description)}'`);
}
emit('');
}
// Options for this command
if (cmd.options.length > 0) {
const condition = `__${BIN}_using_cmd ${path.join(' ')}`;
emit(`# ${path.join(' ')} options`);
for (const opt of cmd.options) {
const parts = [`complete -c ${BIN} -n "${condition}"`];
if (opt.short) parts.push(`-s ${opt.short.replace('-', '')}`);
parts.push(`-l ${opt.long.replace(/^--/, '')}`);
parts.push(`-d '${esc(opt.description)}'`);
if (opt.takesValue) {
if (opt.choices) {
parts.push(`-xa '${opt.choices.join(' ')}'`);
} else {
parts.push('-x');
}
}
emit(parts.join(' '));
}
emit('');
}
}
return lines.join('\n') + '\n';
}
// ============================================================
// Bash completion generator
// ============================================================
function generateBash(root: CmdInfo): string {
const lines: string[] = [];
const emit = (s: string): void => { lines.push(s); };
const BIN = root.name;
emit(`# ${BIN} bash completions -- auto-generated by scripts/generate-completions.ts`);
emit('# DO NOT EDIT MANUALLY -- run: pnpm completions:generate');
emit('');
const allCmds = collectCommands(root);
const topCmds = root.subcommands.filter((c) => !c.hidden).map((c) => c.name);
emit(`_${BIN}() {`);
emit(' local cur prev words cword');
emit(' _init_completion || return');
emit('');
emit(` local top_commands="${topCmds.join(' ')}"`);
emit('');
// Build chain of subcommands from command line
emit(' # Extract the subcommand chain (skip options and their values)');
emit(' local -a subcmd_chain=()');
emit(' local i skip_next=false');
emit(' for ((i=1; i < cword; i++)); do');
emit(' if $skip_next; then skip_next=false; continue; fi');
emit(' case "${words[i]}" in');
emit(' -*) ;; # skip options');
emit(' *) subcmd_chain+=("${words[i]}") ;;');
emit(' esac');
emit(' done');
emit('');
emit(' local chain_len=${#subcmd_chain[@]}');
emit(' local chain_str="${subcmd_chain[*]}"');
emit('');
// Build case statement for each command path
emit(' case "$chain_str" in');
// Start with the deepest paths first to match longest
const sortedCmds = [...allCmds].sort((a, b) => b.path.length - a.path.length);
for (const { path, cmd } of sortedCmds) {
if (cmd.hidden) continue;
const pathStr = path.join(' ');
const visibleSubs = cmd.subcommands.filter((s) => !s.hidden).map((s) => s.name);
const optFlags: string[] = [];
for (const opt of cmd.options) {
if (opt.short) optFlags.push(opt.short);
optFlags.push(opt.long);
}
optFlags.push('-h', '--help');
const completions = [...visibleSubs, ...optFlags].join(' ');
emit(` "${pathStr}")`);
emit(` COMPREPLY=($(compgen -W "${completions}" -- "$cur"))`);
emit(' return ;;');
}
// Top-level (no subcommand yet)
emit(' "")');
emit(` COMPREPLY=($(compgen -W "$top_commands -h --help -v --version" -- "$cur"))`);
emit(' return ;;');
// Default
emit(' *)');
emit(' COMPREPLY=($(compgen -W "-h --help" -- "$cur"))');
emit(' return ;;');
emit(' esac');
emit('}');
emit('');
emit(`complete -F _${BIN} ${BIN}`);
return lines.join('\n') + '\n';
}
// ============================================================
// Main
// ============================================================
async function main(): Promise<void> {
const mode = process.argv[2] ?? '';
let tree: CmdInfo;
try {
tree = await extractTree();
} catch (err) {
console.error('Failed to extract command tree from createProgram().');
console.error('Make sure workspace packages are built: pnpm build');
console.error(err);
process.exit(1);
}
const fishContent = generateFish(tree);
const bashContent = generateBash(tree);
const completionsDir = join(ROOT, 'completions');
const fishPath = join(completionsDir, 'lab.fish');
const bashPath = join(completionsDir, 'lab.bash');
if (mode === '--check') {
let stale = false;
try {
const currentFish = readFileSync(fishPath, 'utf-8');
if (currentFish !== fishContent) {
console.error('completions/lab.fish is stale');
stale = true;
}
} catch {
console.error('completions/lab.fish does not exist');
stale = true;
}
try {
const currentBash = readFileSync(bashPath, 'utf-8');
if (currentBash !== bashContent) {
console.error('completions/lab.bash is stale');
stale = true;
}
} catch {
console.error('completions/lab.bash does not exist');
stale = true;
}
if (stale) {
console.error('Run: pnpm completions:generate');
process.exit(1);
}
console.log('Completions are up to date.');
process.exit(0);
}
if (mode === '--write') {
mkdirSync(completionsDir, { recursive: true });
writeFileSync(fishPath, fishContent);
writeFileSync(bashPath, bashContent);
console.log(`Wrote ${fishPath}`);
console.log(`Wrote ${bashPath}`);
process.exit(0);
}
// Default: print to stdout
console.log('=== completions/lab.fish ===');
console.log(fishContent);
console.log('=== completions/lab.bash ===');
console.log(bashContent);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

65
bastion/scripts/link-package.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Link a Gitea package to a repository.
# Works automatically on Gitea 1.24+ (uses API), warns on older versions.
#
# Usage: source scripts/link-package.sh
# link_package <type> <name>
#
# Requires: GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO
link_package() {
local PKG_TYPE="$1" # e.g. "rpm", "container"
local PKG_NAME="$2" # e.g. "lab", "lab-bastion"
if [ -z "$PKG_TYPE" ] || [ -z "$PKG_NAME" ]; then
echo "Usage: link_package <type> <name>"
return 1
fi
local GITEA_URL="${GITEA_URL:-http://10.0.0.194:3012}"
local GITEA_OWNER="${GITEA_OWNER:-michal}"
local GITEA_REPO="${GITEA_REPO:-lab}"
if [ -z "$GITEA_TOKEN" ]; then
echo "WARNING: GITEA_TOKEN not set, skipping package-repo linking."
return 0
fi
# Check if already linked (search all packages, filter by type+name client-side)
local REPO_LINK
REPO_LINK=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/packages/${GITEA_OWNER}" \
| python3 -c "
import json,sys
for p in json.load(sys.stdin):
if p['type']=='$PKG_TYPE' and p['name']=='$PKG_NAME':
r=p.get('repository')
if r: print(r['full_name'])
break
" 2>/dev/null)
if [ -n "$REPO_LINK" ]; then
echo "==> Package ${PKG_TYPE}/${PKG_NAME} already linked to ${REPO_LINK}"
return 0
fi
# Try Gitea 1.24+ link API
local HTTP_CODE
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/packages/${GITEA_OWNER}/${PKG_TYPE}/${PKG_NAME}/-/link/${GITEA_REPO}")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "==> Linked ${PKG_TYPE}/${PKG_NAME} to ${GITEA_OWNER}/${GITEA_REPO}"
return 0
fi
# API not available (Gitea < 1.24) -- warn with manual instructions
local PUBLIC_URL="${GITEA_PUBLIC_URL:-${GITEA_URL}}"
echo ""
echo "WARNING: Could not auto-link ${PKG_TYPE}/${PKG_NAME} to repository (Gitea < 1.24)."
echo "Link it manually in the Gitea UI:"
echo " ${PUBLIC_URL}/${GITEA_OWNER}/-/packages/${PKG_TYPE}/${PKG_NAME}/settings"
echo " -> Link to repository: ${GITEA_OWNER}/${GITEA_REPO}"
return 0
}

72
bastion/scripts/publish-deb.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env if present
if [ -f .env ]; then
set -a; source .env; set +a
fi
GITEA_URL="${GITEA_URL:-http://10.0.0.194:3012}"
GITEA_PUBLIC_URL="${GITEA_PUBLIC_URL:-https://mysources.co.uk}"
GITEA_OWNER="${GITEA_OWNER:-michal}"
GITEA_REPO="${GITEA_REPO:-lab}"
GITEA_TOKEN="${GITEA_TOKEN:-$PACKAGES_TOKEN}"
if [ -z "$GITEA_TOKEN" ]; then
echo "Error: GITEA_TOKEN (or PACKAGES_TOKEN) not set. Add it to .env or export it."
exit 1
fi
DEB_FILE=$(ls dist/lab*.deb 2>/dev/null | head -1)
if [ -z "$DEB_FILE" ]; then
echo "Error: No DEB found in dist/. Run scripts/build-rpm.sh first."
exit 1
fi
# Extract version from the deb filename
DEB_VERSION=$(dpkg-deb --field "$DEB_FILE" Version 2>/dev/null || echo "unknown")
echo "==> Publishing $DEB_FILE (version $DEB_VERSION) to ${GITEA_URL}..."
# Gitea Debian registry: PUT /api/packages/{owner}/debian/pool/{distribution}/{component}/upload
# Publish to each supported distribution.
# Debian: trixie (13/stable), forky (14/testing)
# Ubuntu: noble (24.04 LTS), plucky (25.04)
DISTRIBUTIONS="trixie forky noble plucky"
for DIST in $DISTRIBUTIONS; do
echo " -> $DIST..."
HTTP_CODE=$(curl -s -o /tmp/deb-upload-$DIST.out -w "%{http_code}" \
-X PUT \
-H "Authorization: token ${GITEA_TOKEN}" \
--upload-file "$DEB_FILE" \
"${GITEA_URL}/api/packages/${GITEA_OWNER}/debian/pool/${DIST}/main/upload")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo " Published to $DIST"
elif [ "$HTTP_CODE" = "409" ]; then
echo " Already exists in $DIST (skipping)"
else
echo " WARNING: Upload to $DIST returned HTTP $HTTP_CODE"
cat /tmp/deb-upload-$DIST.out 2>/dev/null || true
echo ""
fi
rm -f /tmp/deb-upload-$DIST.out
done
echo ""
echo "==> Published successfully!"
# Ensure package is linked to the repository
source "$SCRIPT_DIR/link-package.sh"
link_package "debian" "lab"
echo ""
echo "Install with:"
echo " echo \"deb ${GITEA_PUBLIC_URL}/api/packages/${GITEA_OWNER}/debian trixie main\" | sudo tee /etc/apt/sources.list.d/lab.list"
echo " curl -fsSL ${GITEA_PUBLIC_URL}/api/packages/${GITEA_OWNER}/debian/repository.key | sudo gpg --dearmor -o /etc/apt/keyrings/lab.gpg"
echo " sudo apt update && sudo apt install lab"

62
bastion/scripts/publish-rpm.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env if present
if [ -f .env ]; then
set -a; source .env; set +a
fi
GITEA_URL="${GITEA_URL:-http://10.0.0.194:3012}"
GITEA_PUBLIC_URL="${GITEA_PUBLIC_URL:-https://mysources.co.uk}"
GITEA_OWNER="${GITEA_OWNER:-michal}"
GITEA_REPO="${GITEA_REPO:-lab}"
GITEA_TOKEN="${GITEA_TOKEN:-$PACKAGES_TOKEN}"
if [ -z "$GITEA_TOKEN" ]; then
echo "Error: GITEA_TOKEN (or PACKAGES_TOKEN) not set. Add it to .env or export it."
exit 1
fi
RPM_FILE=$(ls dist/lab-*.rpm 2>/dev/null | head -1)
if [ -z "$RPM_FILE" ]; then
echo "Error: No RPM found in dist/. Run scripts/build-rpm.sh first."
exit 1
fi
# Get version string as it appears in Gitea (e.g. "0.1.0-1")
RPM_VERSION=$(rpm -qp --queryformat '%{VERSION}-%{RELEASE}' "$RPM_FILE")
echo "==> Publishing $RPM_FILE (version $RPM_VERSION) to ${GITEA_URL}..."
# Check if version already exists and delete it first
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/packages/${GITEA_OWNER}/rpm/lab/${RPM_VERSION}")
if [ "$EXISTING" = "200" ]; then
echo "==> Version $RPM_VERSION already exists, replacing..."
curl -s -o /dev/null -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/packages/${GITEA_OWNER}/rpm/lab/${RPM_VERSION}"
fi
# Upload
curl --fail -s -X PUT \
-H "Authorization: token ${GITEA_TOKEN}" \
--upload-file "$RPM_FILE" \
"${GITEA_URL}/api/packages/${GITEA_OWNER}/rpm/upload"
echo ""
echo "==> Published successfully!"
# Ensure package is linked to the repository
source "$SCRIPT_DIR/link-package.sh"
link_package "rpm" "lab"
echo ""
echo "Install with:"
echo " sudo dnf install lab # if repo already configured"