#!/usr/bin/env node import assert from "node:assert/strict"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { readDeployConfig, writeDeployConfig } from "./src/deploy-config.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultDeployPath = "deploy/deploy.yaml"; const defaultHost = "74.48.78.17"; const args = parseArgs(process.argv.slice(2)); if (args.help) { process.stdout.write(`${usage()}\n`); process.exit(0); } const deploy = await readDeployConfig(repoRoot, args.deployConfig); const result = await configureLane(deploy, args); if (args.write) { await writeDeployConfig(repoRoot, args.deployConfig, deploy); for (const file of result.copiedFiles) { await mkdir(path.dirname(path.join(repoRoot, file.to)), { recursive: true }); await writeFile(path.join(repoRoot, file.to), file.content, "utf8"); } } process.stdout.write(`${JSON.stringify({ ok: true, command: "hwlab-lane-expand", action: args.write ? "configured" : "planned", deployConfig: args.deployConfig, lane: result.lane, fromLane: result.fromLane, wrote: args.write, copiedFiles: result.copiedFiles.map((file) => ({ from: file.from, to: file.to })), config: laneSummary(result.config), next: { render: `node scripts/run-bun.mjs scripts/gitops-render.mjs --lane ${result.lane} --source-branch ${result.config.sourceBranch} --gitops-branch ${result.config.gitopsBranch} --catalog-path ${result.config.artifactCatalog} --image-tag-mode ${result.config.imageTagMode ?? "full"}`, controlPlane: `bun scripts/cli.ts hwlab node control-plane apply --lane ${result.lane} --confirm`, trigger: `bun scripts/cli.ts hwlab node control-plane trigger-current --lane ${result.lane} --confirm` } }, null, 2)}\n`); function parseArgs(argv) { const parsed = { action: "configure", lane: null, fromLane: "v02", deployConfig: defaultDeployPath, host: defaultHost, write: false, force: false, copyLaneFiles: true, help: false, webPort: null, apiPort: null, publicUrl: null }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "configure" || arg === "plan") parsed.action = arg; else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg); else if (arg === "--from") parsed.fromLane = readOption(argv, ++index, arg); else if (arg === "--deploy-config") parsed.deployConfig = readOption(argv, ++index, arg); else if (arg === "--host") parsed.host = readOption(argv, ++index, arg); else if (arg === "--web-port") parsed.webPort = parsePort(readOption(argv, ++index, arg), arg); else if (arg === "--api-port") parsed.apiPort = parsePort(readOption(argv, ++index, arg), arg); else if (arg === "--public-url") parsed.publicUrl = readOption(argv, ++index, arg).replace(/\/+$/u, ""); else if (arg === "--write") parsed.write = true; else if (arg === "--force") parsed.force = true; else if (arg === "--no-copy-lane-files") parsed.copyLaneFiles = false; else if (arg === "--help" || arg === "-h") parsed.help = true; else throw new Error(`unknown argument ${arg}`); } if (!parsed.help) { assertLane(parsed.lane, "--lane"); assertLane(parsed.fromLane, "--from"); assert.notEqual(parsed.lane, parsed.fromLane, "--lane must differ from --from"); } return parsed; } function readOption(argv, index, name) { const value = argv[index]; if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); return value; } function parsePort(value, name) { const parsed = Number.parseInt(String(value), 10); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error(`${name} must be a TCP port`); return parsed; } function usage() { return [ "usage: node scripts/hwlab-lane-expand.mjs configure --lane v03 [--from v02] [--write]", "", "Create or preview a new HWLAB additive runtime lane by editing deploy/deploy.yaml only.", "The command derives branch, namespace, GitOps branch, artifact catalog, runtime path, FRP ports,", "and lane-specific SecretRef names from the target lane id so v04/v05/v06 do not require code edits.", "", "options:", " --lane LANE target lane id such as v03, v04, v05", " --from LANE source lane template; default: v02", " --deploy-config PATH default: deploy/deploy.yaml", " --host HOST public host for default legacy endpoints; default: 74.48.78.17", " --web-port PORT override derived web FRP port", " --api-port PORT override derived API FRP port", " --public-url URL override derived HTTPS public endpoint", " --write write deploy config and copied lane prompt files", " --force replace an existing target lane", " --no-copy-lane-files do not copy lane-scoped source files referenced by config" ].join("\n"); } async function configureLane(deploy, options) { deploy.lanes ??= {}; const source = deploy.lanes[options.fromLane]; assert.ok(source && typeof source === "object", `deploy.lanes.${options.fromLane} is required`); if (deploy.lanes[options.lane] && !options.force) { throw new Error(`deploy.lanes.${options.lane} already exists; pass --force to replace it`); } const sourceInfo = laneInfo(options.fromLane, { host: options.host }); const targetInfo = laneInfo(options.lane, { host: options.host, webPort: options.webPort, apiPort: options.apiPort, publicUrl: options.publicUrl }); const replacements = laneReplacements(source, sourceInfo, targetInfo); const target = deepTransform(clone(source), replacements); target.name = targetInfo.versionName; target.node = target.node ?? source.node; assert.ok(target.node, "target lane node is required; set deploy.lanes..node or source lane node"); target.sourceBranch = targetInfo.sourceBranch; target.gitopsBranch = targetInfo.gitopsBranch; target.namespace = targetInfo.namespace; target.endpoint = targetInfo.publicUrl; target.publicEndpoints = { ...(target.publicEndpoints ?? {}), frontend: targetInfo.publicUrl, api: targetInfo.publicUrl, legacyFrontend: `http://${options.host}:${targetInfo.webPort}`, legacyApi: `http://${options.host}:${targetInfo.apiPort}` }; target.artifactCatalog = `deploy/artifact-catalog.${options.lane}.json`; target.runtimePath = `runtime-${options.lane}`; target.imageTagMode = target.imageTagMode ?? "full"; deploy.lanes[options.lane] = target; const copiedFiles = options.copyLaneFiles ? await copiedLaneFiles(target, replacements, sourceInfo, targetInfo) : []; return { lane: options.lane, fromLane: options.fromLane, config: target, copiedFiles }; } function assertLane(value, label) { if (!/^v[0-9]{2,}$/u.test(String(value ?? ""))) throw new Error(`${label} must look like v03, v04, v05`); } function laneInfo(lane, options = {}) { assertLane(lane, "lane"); const minor = Number.parseInt(lane.slice(1), 10); const webPort = options.webPort ?? 17666 + minor * 1000; const apiPort = options.apiPort ?? webPort + 1; const versionName = `v0.${minor}`; const host = options.host ?? defaultHost; const hostLabel = host.replaceAll(".", "-"); return { lane, minor, host, versionName, sourceBranch: versionName, gitopsBranch: `${versionName}-gitops`, namespace: `hwlab-${lane}`, webPort, apiPort, publicUrl: options.publicUrl ?? `https://hwlab-${lane}.${hostLabel}.nip.io` }; } function laneReplacements(sourceConfig, sourceInfo, targetInfo) { const sourceLegacy = sourceConfig?.publicEndpoints ?? {}; return [ [sourceInfo.versionName, targetInfo.versionName], [sourceInfo.sourceBranch, targetInfo.sourceBranch], [sourceInfo.gitopsBranch, targetInfo.gitopsBranch], [sourceInfo.namespace, targetInfo.namespace], [sourceInfo.lane, targetInfo.lane], [`runtime-${sourceInfo.lane}`, `runtime-${targetInfo.lane}`], [`artifact-catalog.${sourceInfo.lane}.json`, `artifact-catalog.${targetInfo.lane}.json`], [`usr_${sourceInfo.lane}_admin`, `usr_${targetInfo.lane}_admin`], [`${sourceInfo.lane}-redacted-presence-only`, `${targetInfo.lane}-redacted-presence-only`], [String(sourceLegacy.legacyFrontend ?? `http://${sourceInfo.host}:${sourceInfo.webPort}`), `http://${targetInfo.host}:${targetInfo.webPort}`], [String(sourceLegacy.legacyApi ?? `http://${sourceInfo.host}:${sourceInfo.apiPort}`), `http://${targetInfo.host}:${targetInfo.apiPort}`], [String(sourceLegacy.frontend ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl], [String(sourceLegacy.api ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl] ].filter(([from]) => from); } function deepTransform(value, replacements) { if (typeof value === "string") return replaceAll(value, replacements); if (Array.isArray(value)) return value.map((item) => deepTransform(item, replacements)); if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value).map(([key, item]) => [replaceAll(key, replacements), deepTransform(item, replacements)])); } return value; } function replaceAll(value, replacements) { let text = String(value); for (const [from, to] of replacements) text = text.split(from).join(to); return text; } async function copiedLaneFiles(target, replacements, sourceInfo, targetInfo) { const paths = new Set(); collectRepoPaths(target, paths); const copied = []; for (const targetPath of paths) { if (!targetPath.includes(targetInfo.lane) && !targetPath.includes(targetInfo.versionName)) continue; const sourcePath = targetPath .split(targetInfo.lane).join(sourceInfo.lane) .split(targetInfo.versionName).join(sourceInfo.versionName); const sourceAbs = path.join(repoRoot, sourcePath); const targetAbs = path.join(repoRoot, targetPath); if (sourceAbs === targetAbs) continue; let raw; try { raw = await readFile(sourceAbs, "utf8"); } catch (error) { if (error?.code === "ENOENT") continue; throw error; } try { await readFile(targetAbs, "utf8"); continue; } catch (error) { if (error?.code !== "ENOENT") throw error; } copied.push({ from: sourcePath, to: targetPath, content: replaceAll(raw, replacements) }); } return copied; } function collectRepoPaths(value, result) { if (typeof value === "string") { if (/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/u.test(value) && !value.startsWith("http")) result.add(value); return; } if (Array.isArray(value)) { for (const item of value) collectRepoPaths(item, result); return; } if (value && typeof value === "object") { for (const item of Object.values(value)) collectRepoPaths(item, result); } } function laneSummary(config) { return { name: config.name, sourceBranch: config.sourceBranch, gitopsBranch: config.gitopsBranch, namespace: config.namespace, artifactCatalog: config.artifactCatalog, runtimePath: config.runtimePath, publicEndpoints: config.publicEndpoints, serviceCount: Array.isArray(config.envReuseServices) ? config.envReuseServices.length : null }; } function clone(value) { return JSON.parse(JSON.stringify(value)); }