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