| 1 | // Computer registry: named computers (local, ssh, hdc) with a sticky active |
| 2 | // computer. Every codewhale-cu tool defaults to the active computer; passing |
| 3 | // `computer` on any tool switches to it first (switch-by-use). |
| 4 | import fs from "node:fs"; |
| 5 | import path from "node:path"; |
| 6 | import os from "node:os"; |
| 7 | import { validateSshTarget } from "./ssh-args.mjs"; |
| 8 | |
| 9 | export class RegistryError extends Error { |
| 10 | constructor(code, message) { super(message); this.name = "RegistryError"; this.code = code; } |
| 11 | } |
| 12 | |
| 13 | const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/; |
| 14 | |
| 15 | export function stateDir() { |
| 16 | return process.env.CODEWHALE_CU_STATE_DIR || path.join(os.homedir(), ".codewhale-cu"); |
| 17 | } |
| 18 | |
| 19 | function registryPath() { return path.join(stateDir(), "computers.json"); } |
| 20 | |
| 21 | const PLATFORM = process.platform; // darwin | linux | win32 |
| 22 | |
| 23 | function localEntry() { |
| 24 | return { |
| 25 | id: "local", |
| 26 | transport: "local", |
| 27 | platform: PLATFORM, |
| 28 | label: `This ${PLATFORM === "darwin" ? "Mac" : PLATFORM === "win32" ? "Windows PC" : "Linux machine"}`, |
| 29 | registeredAt: new Date().toISOString(), |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | /** An entry read from computers.json that could not have been registered. */ |
| 34 | function unusableEntry(entry) { |
| 35 | if (!entry || typeof entry !== "object") return true; |
| 36 | if (entry.transport !== "ssh") return false; |
| 37 | try { validateSshTarget(entry); return false; } catch { return true; } |
| 38 | } |
| 39 | |
| 40 | export function load() { |
| 41 | try { |
| 42 | const raw = JSON.parse(fs.readFileSync(registryPath(), "utf8")); |
| 43 | if (!raw || typeof raw !== "object" || !raw.computers) throw new Error("bad shape"); |
| 44 | // The file is state, not authority: an entry that registration would |
| 45 | // refuse (an ssh host or user that reads as an option) is dropped. |
| 46 | for (const [id, entry] of Object.entries(raw.computers)) { |
| 47 | if (unusableEntry(entry)) delete raw.computers[id]; |
| 48 | } |
| 49 | return raw; |
| 50 | } catch { |
| 51 | const fresh = { version: 1, active: "local", computers: { local: localEntry() } }; |
| 52 | return fresh; |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | export function save(reg) { |
| 57 | fs.mkdirSync(stateDir(), { recursive: true }); |
| 58 | const tmp = registryPath() + ".tmp"; |
| 59 | fs.writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n"); |
| 60 | fs.renameSync(tmp, registryPath()); |
| 61 | return reg; |
| 62 | } |
| 63 | |
| 64 | export function list() { |
| 65 | const reg = load(); |
| 66 | // Keep the local entry truthful even if the state file is stale. |
| 67 | reg.computers.local = { ...(reg.computers.local ?? {}), ...localEntry() }; |
| 68 | if (!reg.computers[reg.active]) reg.active = "local"; |
| 69 | return reg; |
| 70 | } |
| 71 | |
| 72 | export function get(id) { |
| 73 | const reg = list(); |
| 74 | const c = reg.computers[id]; |
| 75 | if (!c) throw new RegistryError("unknown_computer", `no computer registered with id "${id}". Use computer_list.`); |
| 76 | return c; |
| 77 | } |
| 78 | |
| 79 | export function active() { return get(list().active); } |
| 80 | |
| 81 | /** Switch the active computer. Returns the computer entry. */ |
| 82 | export function switchTo(id) { |
| 83 | const c = get(id); // throws unknown_computer |
| 84 | const reg = load(); |
| 85 | reg.active = c.id; |
| 86 | save(reg); |
| 87 | return c; |
| 88 | } |
| 89 | |
| 90 | /** Register or update a computer. Returns the entry. */ |
| 91 | export function register({ id, transport, label, ...rest }) { |
| 92 | if (!id || !ID_RE.test(id)) throw new RegistryError("invalid_id", "computer id must match " + ID_RE); |
| 93 | if (!["local", "ssh", "hdc", "docker"].includes(transport)) { |
| 94 | throw new RegistryError("invalid_transport", "transport must be one of: local, ssh, hdc, docker"); |
| 95 | } |
| 96 | if (id === "local" && transport !== "local") { |
| 97 | throw new RegistryError("reserved_id", '"local" is reserved for this machine'); |
| 98 | } |
| 99 | if (transport === "ssh") { |
| 100 | try { |
| 101 | validateSshTarget(rest); |
| 102 | } catch (e) { |
| 103 | throw new RegistryError(e.code ?? "invalid_host", e.message); |
| 104 | } |
| 105 | } |
| 106 | if (transport === "hdc") { |
| 107 | if (rest.target != null && !/^[A-Za-z0-9._-]*$/.test(rest.target)) { |
| 108 | throw new RegistryError("invalid_target", "hdc target key contains invalid characters"); |
| 109 | } |
| 110 | rest.platform = "harmonyos"; |
| 111 | } |
| 112 | if (transport === "docker") { |
| 113 | if (!rest.container || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(rest.container)) { |
| 114 | throw new RegistryError("invalid_container", "docker computers need a valid container name"); |
| 115 | } |
| 116 | // Spawned containers always run the Linux desktop image. |
| 117 | rest.platform = rest.platform ?? "linux"; |
| 118 | } |
| 119 | const reg = load(); |
| 120 | const prev = reg.computers[id]; |
| 121 | reg.computers[id] = { |
| 122 | ...prev, |
| 123 | id, |
| 124 | transport, |
| 125 | label: label ?? prev?.label ?? id, |
| 126 | registeredAt: prev?.registeredAt ?? new Date().toISOString(), |
| 127 | ...rest, |
| 128 | }; |
| 129 | save(reg); |
| 130 | return reg.computers[id]; |
| 131 | } |
| 132 | |
| 133 | export function remove(id) { |
| 134 | if (id === "local") throw new RegistryError("reserved_id", '"local" cannot be removed'); |
| 135 | const reg = load(); |
| 136 | if (!reg.computers[id]) throw new RegistryError("unknown_computer", `no computer registered with id "${id}"`); |
| 137 | delete reg.computers[id]; |
| 138 | if (reg.active === id) reg.active = "local"; |
| 139 | save(reg); |
| 140 | return { removed: id, active: reg.active }; |
| 141 | } |
| 142 |