Initial vmc CLI
This commit is contained in:
38
src/lib/confirm.ts
Normal file
38
src/lib/confirm.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import readline from "node:readline/promises";
|
||||
|
||||
export async function requireConfirmation(opts: {
|
||||
action: string;
|
||||
server: { id: number; name: string; ip?: string | null };
|
||||
reason?: string;
|
||||
yes?: boolean;
|
||||
match?: "id" | "exact" | "partial";
|
||||
}) {
|
||||
if (opts.yes) return;
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
const err = new Error("Confirmation required. Re-run with --yes.");
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const reason = opts.reason?.trim() || "unspecified";
|
||||
const ip = opts.server.ip ? `, ip=${opts.server.ip}` : "";
|
||||
const matchNote = opts.match === "partial" ? " [partial match]" : "";
|
||||
const prompt = `Confirm ${opts.action}${matchNote} on ${opts.server.name} (id=${opts.server.id}${ip}) reason=\"${reason}\"? [y/N] `;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
try {
|
||||
const answer = await rl.question(prompt);
|
||||
if (!/^y(es)?$/i.test(answer.trim())) {
|
||||
const err = new Error("Confirmation declined.");
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
23
src/lib/env.ts
Normal file
23
src/lib/env.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
export function loadEnv() {
|
||||
const homeEnv = path.join(os.homedir(), ".env");
|
||||
const localEnv = path.join(process.cwd(), ".env");
|
||||
|
||||
if (fs.existsSync(homeEnv)) dotenv.config({ path: homeEnv });
|
||||
if (fs.existsSync(localEnv)) dotenv.config({ path: localEnv });
|
||||
}
|
||||
|
||||
export function requireHcloudToken(): string {
|
||||
loadEnv();
|
||||
const token = process.env.HCLOUD_TOKEN?.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"HCLOUD_TOKEN missing. Put it in ~/.env or ./vm-cloud/.env as: HCLOUD_TOKEN=xxxxx"
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
17
src/lib/hash.ts
Normal file
17
src/lib/hash.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
|
||||
export function canonicalBytes(obj: unknown): Uint8Array {
|
||||
const json = canonicalize(obj);
|
||||
return new TextEncoder().encode(json);
|
||||
}
|
||||
|
||||
export function hashBlake3Hex(obj: unknown): string {
|
||||
return bytesToHex(blake3(canonicalBytes(obj)));
|
||||
}
|
||||
|
||||
export function hashSha256Hex(obj: unknown): string {
|
||||
return bytesToHex(sha256(canonicalBytes(obj)));
|
||||
}
|
||||
75
src/lib/hcloud.ts
Normal file
75
src/lib/hcloud.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface HetznerServer {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
created: string;
|
||||
public_net: { ipv4: { ip: string | null } };
|
||||
server_type: { name: string; cores: number; memory: number; disk?: number };
|
||||
datacenter: { name: string; location: { city: string; country?: string } };
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
type HcloudListServersResponse = {
|
||||
servers: HetznerServer[];
|
||||
};
|
||||
|
||||
export type HcloudResponse<T = unknown> = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: T | null;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export class HcloudClient {
|
||||
private base = "https://api.hetzner.cloud/v1";
|
||||
constructor(private token: string) {}
|
||||
|
||||
private async requestWithMeta<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<HcloudResponse<T>> {
|
||||
const res = await fetch(`${this.base}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
const raw = await res.text().catch(() => "");
|
||||
let data: T | null = null;
|
||||
if (raw) {
|
||||
try {
|
||||
data = JSON.parse(raw) as T;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: res.ok, status: res.status, data, raw };
|
||||
}
|
||||
|
||||
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await this.requestWithMeta<T>(method, path, body);
|
||||
if (!res.ok) {
|
||||
const detail = res.raw || "unknown error";
|
||||
throw new Error(`Hetzner API ${res.status} error: ${detail}`);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
async listServers(): Promise<HetznerServer[]> {
|
||||
const data = await this.req<HcloudListServersResponse>("GET", "/servers");
|
||||
return data.servers ?? [];
|
||||
}
|
||||
|
||||
async powerAction(serverId: number, action: "poweron" | "poweroff" | "reboot") {
|
||||
return this.requestWithMeta("POST", `/servers/${serverId}/actions/${action}`);
|
||||
}
|
||||
|
||||
async updateServerLabels(serverId: number, labels: Record<string, string>) {
|
||||
return this.requestWithMeta("PUT", `/servers/${serverId}`, { labels });
|
||||
}
|
||||
}
|
||||
90
src/lib/keys.ts
Normal file
90
src/lib/keys.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { getPublicKey, sign, verify, utils, etc } from "@noble/ed25519";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { sha512 } from "@noble/hashes/sha512";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
|
||||
const KEY_DIR = path.join(os.homedir(), ".config", "vm-cloud", "keys");
|
||||
const PRIV_PATH = path.join(KEY_DIR, "operator_ed25519.key");
|
||||
const PUB_PATH = path.join(KEY_DIR, "operator_ed25519.pub");
|
||||
|
||||
if (!etc.sha512Sync) {
|
||||
etc.sha512Sync = (...messages) => sha512(etc.concatBytes(...messages));
|
||||
}
|
||||
|
||||
function ensureKeyDir() {
|
||||
fs.mkdirSync(KEY_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function keyPaths() {
|
||||
return { dir: KEY_DIR, privateKey: PRIV_PATH, publicKey: PUB_PATH };
|
||||
}
|
||||
|
||||
export function readPrivateKey(): Uint8Array {
|
||||
if (!fs.existsSync(PRIV_PATH)) {
|
||||
const err = new Error(`Private key not found: ${PRIV_PATH}`);
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
const hex = fs.readFileSync(PRIV_PATH, "utf8").trim();
|
||||
return hexToBytes(hex);
|
||||
}
|
||||
|
||||
export function readPublicKey(): Uint8Array {
|
||||
if (!fs.existsSync(PUB_PATH)) {
|
||||
const err = new Error(`Public key not found: ${PUB_PATH}`);
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
const hex = fs.readFileSync(PUB_PATH, "utf8").trim();
|
||||
return hexToBytes(hex);
|
||||
}
|
||||
|
||||
export async function generateKeypair(opts: { force?: boolean } = {}) {
|
||||
ensureKeyDir();
|
||||
if (!opts.force && (fs.existsSync(PRIV_PATH) || fs.existsSync(PUB_PATH))) {
|
||||
const err = new Error(
|
||||
`Key already exists. Use --force to overwrite: ${PRIV_PATH}`
|
||||
);
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const priv = utils.randomPrivateKey();
|
||||
const pub = await getPublicKey(priv);
|
||||
|
||||
fs.writeFileSync(PRIV_PATH, bytesToHex(priv), { mode: 0o600 });
|
||||
fs.writeFileSync(PUB_PATH, bytesToHex(pub), { mode: 0o644 });
|
||||
|
||||
return {
|
||||
privateKeyPath: PRIV_PATH,
|
||||
publicKeyPath: PUB_PATH,
|
||||
publicKey: bytesToHex(pub)
|
||||
};
|
||||
}
|
||||
|
||||
export function keyIdFromPublicHex(publicKeyHex: string) {
|
||||
const pub = hexToBytes(publicKeyHex);
|
||||
return bytesToHex(blake3(pub));
|
||||
}
|
||||
|
||||
export async function signMessage(message: Uint8Array) {
|
||||
const priv = readPrivateKey();
|
||||
const pub = await getPublicKey(priv);
|
||||
const signature = await sign(message, priv);
|
||||
const publicKey = bytesToHex(pub);
|
||||
const signerKid = keyIdFromPublicHex(publicKey);
|
||||
return { signature: bytesToHex(signature), publicKey, signerKid };
|
||||
}
|
||||
|
||||
export async function verifyMessage(
|
||||
message: Uint8Array,
|
||||
signatureHex: string,
|
||||
publicKeyHex: string
|
||||
) {
|
||||
const signature = hexToBytes(signatureHex);
|
||||
const publicKey = hexToBytes(publicKeyHex);
|
||||
return verify(signature, message, publicKey);
|
||||
}
|
||||
39
src/lib/ledger.ts
Normal file
39
src/lib/ledger.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ensureDir } from "./report.js";
|
||||
|
||||
function receiptsDir() {
|
||||
return path.join(process.cwd(), "outputs", "receipts");
|
||||
}
|
||||
|
||||
function headPath() {
|
||||
return path.join(receiptsDir(), "HEAD.json");
|
||||
}
|
||||
|
||||
export type ReceiptHead = { blake3: string; file: string; created_at: string };
|
||||
|
||||
export function readHead(): ReceiptHead | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(headPath(), "utf8");
|
||||
const data = JSON.parse(raw) as ReceiptHead;
|
||||
if (!data?.blake3 || !data?.file || !data?.created_at) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readPrevReceiptHash(): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(headPath(), "utf8");
|
||||
const data = JSON.parse(raw) as { blake3?: string };
|
||||
return typeof data.blake3 === "string" ? data.blake3 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHead(next: { blake3: string; file: string; created_at: string }) {
|
||||
ensureDir(receiptsDir());
|
||||
fs.writeFileSync(headPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
|
||||
}
|
||||
102
src/lib/lock.ts
Normal file
102
src/lib/lock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type LockInfo = {
|
||||
pid: number;
|
||||
user: string;
|
||||
hostname: string;
|
||||
started_at: string;
|
||||
argv: string[];
|
||||
};
|
||||
|
||||
function homeCacheDir() {
|
||||
return path.join(os.homedir(), ".cache", "vm-cloud", "locks");
|
||||
}
|
||||
|
||||
function lockPath(serverId: number) {
|
||||
return path.join(homeCacheDir(), `${serverId}.lock`);
|
||||
}
|
||||
|
||||
function pidAlive(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLockExclusive(lockFile: string, info: LockInfo) {
|
||||
const fd = fs.openSync(lockFile, "wx", 0o600);
|
||||
try {
|
||||
fs.writeFileSync(fd, JSON.stringify(info, null, 2), "utf8");
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireServerLock(
|
||||
serverId: number,
|
||||
opts: { force?: boolean } = {}
|
||||
) {
|
||||
const dir = homeCacheDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const lp = lockPath(serverId);
|
||||
|
||||
const info: LockInfo = {
|
||||
pid: process.pid,
|
||||
user: process.env.USER || "unknown",
|
||||
hostname: os.hostname(),
|
||||
started_at: new Date().toISOString(),
|
||||
argv: process.argv.slice()
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
writeLockExclusive(lp, info);
|
||||
return {
|
||||
file: lp,
|
||||
info,
|
||||
release() {
|
||||
try {
|
||||
fs.unlinkSync(lp);
|
||||
} catch {
|
||||
// Ignore missing lock on release.
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code !== "EEXIST") throw err;
|
||||
|
||||
let existing: LockInfo | null = null;
|
||||
try {
|
||||
existing = JSON.parse(fs.readFileSync(lp, "utf8")) as LockInfo;
|
||||
} catch {
|
||||
existing = null;
|
||||
}
|
||||
|
||||
const alive = existing?.pid ? pidAlive(existing.pid) : false;
|
||||
if (alive && !opts.force) {
|
||||
const lockErr = new Error(
|
||||
`Server is locked by pid=${existing!.pid} user=${existing!.user} host=${existing!.hostname} since=${existing!.started_at}`
|
||||
);
|
||||
(lockErr as { exitCode?: number }).exitCode = 6;
|
||||
(lockErr as { lockInfo?: LockInfo | null }).lockInfo = existing;
|
||||
throw lockErr;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(lp);
|
||||
} catch {
|
||||
// Ignore removal errors so we can attempt to replace.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error("Failed to acquire server lock");
|
||||
(err as { exitCode?: number }).exitCode = 6;
|
||||
throw err;
|
||||
}
|
||||
41
src/lib/plan.ts
Normal file
41
src/lib/plan.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { hashBlake3Hex, hashSha256Hex } from "./hash.js";
|
||||
import { ensureDir, nowStamp } from "./report.js";
|
||||
|
||||
export type PlanAction = "poweron" | "poweroff" | "reboot" | "labels";
|
||||
export type PlanMatch = "id" | "exact" | "partial";
|
||||
|
||||
export type ServerPlan = {
|
||||
plan_version: "1";
|
||||
created_at: string;
|
||||
action: PlanAction;
|
||||
server: { id: number; name: string; ip?: string | null };
|
||||
match: PlanMatch;
|
||||
request: { method: "POST" | "PUT"; path: string; body?: unknown };
|
||||
reason?: string;
|
||||
applied: false;
|
||||
};
|
||||
|
||||
export function resolvePlanPath(planPath: string) {
|
||||
return path.isAbsolute(planPath) ? planPath : path.join(process.cwd(), planPath);
|
||||
}
|
||||
|
||||
export function writePlan(plan: ServerPlan) {
|
||||
const { stamp } = nowStamp();
|
||||
const dir = path.join(process.cwd(), "outputs", "plans");
|
||||
const name = `plan-${stamp}-${plan.action}-${plan.server.id}.json`;
|
||||
ensureDir(dir);
|
||||
const file = path.join(dir, name);
|
||||
const json = JSON.stringify(plan, null, 2);
|
||||
fs.writeFileSync(file, json, "utf8");
|
||||
const sha256 = hashSha256Hex(plan);
|
||||
const blake3 = hashBlake3Hex(plan);
|
||||
return { file, sha256, blake3 };
|
||||
}
|
||||
|
||||
export function readPlan(planPath: string): ServerPlan {
|
||||
const abs = resolvePlanPath(planPath);
|
||||
const raw = fs.readFileSync(abs, "utf8");
|
||||
return JSON.parse(raw) as ServerPlan;
|
||||
}
|
||||
116
src/lib/receipt.ts
Normal file
116
src/lib/receipt.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { hashBlake3Hex, hashSha256Hex } from "./hash.js";
|
||||
import { readPrevReceiptHash, writeHead } from "./ledger.js";
|
||||
import { ensureDir } from "./report.js";
|
||||
|
||||
type ReceiptRequest = {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
type ReceiptResponse = {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
type ReceiptInput = {
|
||||
action: string;
|
||||
reason: string;
|
||||
server: { id: number; name: string; ip?: string | null };
|
||||
request: ReceiptRequest;
|
||||
response: ReceiptResponse;
|
||||
lock?: { file: string; started_at: string; force?: boolean };
|
||||
plan?: { file: string; sha256: string; blake3: string };
|
||||
};
|
||||
|
||||
type ReceiptBody = {
|
||||
receipt_version: "1";
|
||||
created_at: string;
|
||||
cwd: string;
|
||||
user: string;
|
||||
hostname: string;
|
||||
argv: string[];
|
||||
reason: string;
|
||||
lock_file: string | null;
|
||||
lock_started_at: string | null;
|
||||
force: boolean;
|
||||
plan_file: string | null;
|
||||
plan_sha256: string | null;
|
||||
plan_blake3: string | null;
|
||||
target: { id: number; name: string; ip?: string | null };
|
||||
request: ReceiptRequest;
|
||||
response: ReceiptResponse;
|
||||
prev_blake3: string | null;
|
||||
};
|
||||
|
||||
type ReceiptEnvelope = ReceiptBody & {
|
||||
hash_alg: "blake3+sha256";
|
||||
blake3: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
function fileStamp(d = new Date()) {
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
|
||||
}
|
||||
|
||||
export function writeReceipt(input: ReceiptInput) {
|
||||
const dir = path.join(process.cwd(), "outputs", "receipts");
|
||||
ensureDir(dir);
|
||||
|
||||
const file = path.join(
|
||||
dir,
|
||||
`${fileStamp()}-${input.action}-${input.server.id}.json`
|
||||
);
|
||||
|
||||
const created_at = new Date().toISOString();
|
||||
const prev_blake3 = readPrevReceiptHash();
|
||||
const body: ReceiptBody = {
|
||||
receipt_version: "1",
|
||||
created_at,
|
||||
cwd: process.cwd(),
|
||||
user: process.env.USER ?? process.env.LOGNAME ?? "unknown",
|
||||
hostname: os.hostname(),
|
||||
argv: process.argv,
|
||||
reason: input.reason || "unspecified",
|
||||
lock_file: input.lock?.file ?? null,
|
||||
lock_started_at: input.lock?.started_at ?? null,
|
||||
force: Boolean(input.lock?.force),
|
||||
plan_file: input.plan?.file ?? null,
|
||||
plan_sha256: input.plan?.sha256 ?? null,
|
||||
plan_blake3: input.plan?.blake3 ?? null,
|
||||
target: {
|
||||
id: input.server.id,
|
||||
name: input.server.name,
|
||||
ip: input.server.ip ?? null
|
||||
},
|
||||
request: input.request,
|
||||
response: input.response,
|
||||
prev_blake3
|
||||
};
|
||||
|
||||
const blake3 = hashBlake3Hex(body);
|
||||
const sha256 = hashSha256Hex(body);
|
||||
const envelope: ReceiptEnvelope = {
|
||||
...body,
|
||||
hash_alg: "blake3+sha256",
|
||||
blake3,
|
||||
sha256
|
||||
};
|
||||
|
||||
fs.writeFileSync(file, JSON.stringify(envelope, null, 2), "utf8");
|
||||
const relFile = path.relative(process.cwd(), file) || file;
|
||||
writeHead({ blake3, file: relFile, created_at });
|
||||
|
||||
return { file, sha256, blake3 };
|
||||
}
|
||||
68
src/lib/report.ts
Normal file
68
src/lib/report.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function ensureDir(p: string) {
|
||||
fs.mkdirSync(p, { recursive: true });
|
||||
}
|
||||
|
||||
export function nowStamp() {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return { date: `${yyyy}-${mm}-${dd}`, stamp: `${yyyy}${mm}${dd}-${hh}${mi}` };
|
||||
}
|
||||
|
||||
export function slugify(s: string) {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
export function writeJsonSnapshot(dir: string, name: string, data: unknown) {
|
||||
ensureDir(dir);
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, json, "utf8");
|
||||
const sha = crypto.createHash("sha256").update(json).digest("hex");
|
||||
return { file, sha };
|
||||
}
|
||||
|
||||
export function createResearchNote(dir: string, title: string) {
|
||||
ensureDir(dir);
|
||||
const { date } = nowStamp();
|
||||
const slug = slugify(title);
|
||||
const file = path.join(dir, `${date}-${slug}.md`);
|
||||
if (fs.existsSync(file)) return file;
|
||||
|
||||
const body = `# ${title}
|
||||
|
||||
Date: ${date}
|
||||
|
||||
## Context
|
||||
|
||||
- Purpose:
|
||||
- Scope:
|
||||
- Risks:
|
||||
|
||||
## Evidence
|
||||
|
||||
<!-- snapshots go here -->
|
||||
|
||||
## Findings
|
||||
|
||||
-
|
||||
|
||||
## Next actions
|
||||
|
||||
-
|
||||
`;
|
||||
fs.writeFileSync(file, body, "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
export function appendToResearchNote(notePath: string, markdown: string) {
|
||||
const sep = "\n\n";
|
||||
fs.appendFileSync(notePath, sep + markdown, "utf8");
|
||||
}
|
||||
44
src/lib/resolve.ts
Normal file
44
src/lib/resolve.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { HcloudClient, HetznerServer } from "./hcloud.js";
|
||||
|
||||
function isNumericId(input: string) {
|
||||
return /^[0-9]+$/.test(input);
|
||||
}
|
||||
|
||||
export type ServerMatch = {
|
||||
server: HetznerServer;
|
||||
match: "id" | "exact" | "partial";
|
||||
};
|
||||
|
||||
export async function resolveServer(
|
||||
client: HcloudClient,
|
||||
input: string
|
||||
): Promise<ServerMatch> {
|
||||
const needle = input.trim();
|
||||
if (!needle) {
|
||||
throw new Error("Server name or id is required");
|
||||
}
|
||||
|
||||
const servers = await client.listServers();
|
||||
|
||||
if (isNumericId(needle)) {
|
||||
const id = Number(needle);
|
||||
const byId = servers.find((s) => s.id === id);
|
||||
if (byId) return { server: byId, match: "id" };
|
||||
throw new Error(`No server found with id ${id}`);
|
||||
}
|
||||
|
||||
const exact = servers.find((s) => s.name === needle);
|
||||
if (exact) return { server: exact, match: "exact" };
|
||||
|
||||
const lower = needle.toLowerCase();
|
||||
const partial = servers.filter((s) => s.name.toLowerCase().includes(lower));
|
||||
if (partial.length === 1) return { server: partial[0], match: "partial" };
|
||||
if (partial.length > 1) {
|
||||
const names = partial.map((s) => s.name).join(", ");
|
||||
const err = new Error(`Ambiguous name "${needle}". Matches: ${names}`);
|
||||
(err as { exitCode?: number }).exitCode = 3;
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new Error(`No server found with name "${needle}"`);
|
||||
}
|
||||
63
src/lib/signature.ts
Normal file
63
src/lib/signature.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { signMessage } from "./keys.js";
|
||||
|
||||
type ReceiptEnvelope = {
|
||||
blake3: string;
|
||||
sig_alg?: string;
|
||||
signer_pub?: string;
|
||||
signer_kid?: string;
|
||||
signed_at?: string;
|
||||
signature?: string;
|
||||
};
|
||||
|
||||
export async function signReceiptFile(
|
||||
receiptPath: string,
|
||||
opts: { force?: boolean } = {}
|
||||
) {
|
||||
const abs = path.resolve(receiptPath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
const err = new Error(`Receipt not found: ${abs}`);
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(abs, "utf8");
|
||||
let env: ReceiptEnvelope;
|
||||
try {
|
||||
env = JSON.parse(raw) as ReceiptEnvelope;
|
||||
} catch {
|
||||
const err = new Error("Receipt is not valid JSON");
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!env.blake3) {
|
||||
const err = new Error("Receipt missing blake3 field");
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if ((env.signature || env.signer_pub) && !opts.force) {
|
||||
const err = new Error("Receipt already signed. Use --force to overwrite.");
|
||||
(err as { exitCode?: number }).exitCode = 2;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const msg = new TextEncoder().encode(env.blake3);
|
||||
const { signature, publicKey, signerKid } = await signMessage(msg);
|
||||
const signedAt = new Date().toISOString();
|
||||
|
||||
const next = {
|
||||
...env,
|
||||
sig_alg: "ed25519",
|
||||
signer_pub: publicKey,
|
||||
signer_kid: signerKid,
|
||||
signed_at: signedAt,
|
||||
signature
|
||||
};
|
||||
|
||||
fs.writeFileSync(abs, JSON.stringify(next, null, 2), "utf8");
|
||||
|
||||
return { file: abs, signer_pub: publicKey, signer_kid: signerKid, signed_at: signedAt, signature };
|
||||
}
|
||||
1
src/lib/wireguard.ts
Normal file
1
src/lib/wireguard.ts
Normal file
@@ -0,0 +1 @@
|
||||
// Placeholder for WireGuard mesh utilities.
|
||||
Reference in New Issue
Block a user