microsoft/TypeAgent
Publicmirrored from https://github.com/microsoft/TypeAgentAvailable
ts/tools/scripts/build-msi.mjs
300lines · modecode
| 1 | #!/usr/bin/env node |
| 2 | // Copyright (c) Microsoft Corporation. |
| 3 | // Licensed under the MIT License. |
| 4 | |
| 5 | // Copyright (c) Microsoft Corporation. Licensed under the MIT License. |
| 6 | |
| 7 | /** |
| 8 | * build-msi.mjs |
| 9 | * |
| 10 | * Orchestrates the TypeAgent MSI build: |
| 11 | * 1. Download agent-server.<rid> artifact from ADO feed |
| 12 | * 2. Download typeagent-copilot-plugin artifact from ADO feed |
| 13 | * 3. Generate marketplace.json for Copilot CLI plugin registration |
| 14 | * 4. Harvest file components with heat.exe (one pass per artifact dir) |
| 15 | * 5. Compile WiX (candle) + link to MSI (light) |
| 16 | * |
| 17 | * Usage: |
| 18 | * node build-msi.mjs --rid win32-x64 --version 0.0.1-12345 --output ./out |
| 19 | * node build-msi.mjs --rid win32-x64 --version 0.0.1-12345 --plugin-version 0.0.1-12345 |
| 20 | * node build-msi.mjs --skip-download --version 0.0.1-test --output ./out |
| 21 | */ |
| 22 | |
| 23 | import fs from "node:fs"; |
| 24 | import path from "node:path"; |
| 25 | import { spawnSync } from "node:child_process"; |
| 26 | import { fileURLToPath } from "node:url"; |
| 27 | |
| 28 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 29 | |
| 30 | // ── Argument parsing ────────────────────────────────────────────────────────── |
| 31 | const args = process.argv.slice(2); |
| 32 | let rid = "win32-x64"; |
| 33 | let version = "latest"; |
| 34 | let pluginVersion = "latest"; |
| 35 | let outputDir = "./msi-out"; |
| 36 | let skipDownload = false; |
| 37 | |
| 38 | for (let i = 0; i < args.length; i++) { |
| 39 | if (args[i] === "--rid") rid = args[++i]; |
| 40 | else if (args[i] === "--version") version = args[++i]; |
| 41 | else if (args[i] === "--plugin-version") pluginVersion = args[++i]; |
| 42 | else if (args[i] === "--output") outputDir = args[++i]; |
| 43 | else if (args[i] === "--skip-download") skipDownload = true; |
| 44 | } |
| 45 | |
| 46 | console.log(`📦 Building TypeAgent MSI`); |
| 47 | console.log(` RID: ${rid}`); |
| 48 | console.log(` Agent version: ${version}`); |
| 49 | console.log(` Plugin version: ${pluginVersion}`); |
| 50 | console.log(` Output: ${outputDir}`); |
| 51 | |
| 52 | // ── Paths ───────────────────────────────────────────────────────────────────── |
| 53 | const wxsDir = path.resolve(__dirname, "../installers/wix"); |
| 54 | const wxsFile = path.join(wxsDir, "TypeAgent-AgentServer.wxs"); |
| 55 | const outputPath = path.resolve(outputDir); |
| 56 | const agentArtifactDir = path.join(outputPath, "artifact", "agent-server"); |
| 57 | const pluginArtifactDir = path.join(outputPath, "artifact", "copilot-plugin"); |
| 58 | const marketplaceDir = path.join(outputPath, "marketplace"); |
| 59 | const agentHeatFile = path.join(outputPath, "AgentServerFiles.wxs"); |
| 60 | const pluginHeatFile = path.join(outputPath, "CopilotPluginFiles.wxs"); |
| 61 | |
| 62 | if (!fs.existsSync(outputPath)) fs.mkdirSync(outputPath, { recursive: true }); |
| 63 | |
| 64 | // ── Helpers ─────────────────────────────────────────────────────────────────── |
| 65 | function runCommand(cmd, cmdArgs, options = {}) { |
| 66 | console.log(`\n▶ ${cmd} ${cmdArgs.join(" ")}`); |
| 67 | const result = spawnSync(cmd, cmdArgs, { |
| 68 | stdio: "inherit", |
| 69 | shell: false, |
| 70 | ...options, |
| 71 | }); |
| 72 | if (result.error) { |
| 73 | console.error(`❌ Command failed: ${result.error.message}`); |
| 74 | process.exit(1); |
| 75 | } |
| 76 | if (result.status !== 0) { |
| 77 | console.error(`❌ Command exited with code ${result.status}`); |
| 78 | process.exit(1); |
| 79 | } |
| 80 | return result; |
| 81 | } |
| 82 | |
| 83 | function findExe(candidates) { |
| 84 | return candidates.find((p) => fs.existsSync(p)) ?? null; |
| 85 | } |
| 86 | |
| 87 | function discoverWixBinDirs() { |
| 88 | const roots = ["C:\\Program Files (x86)", "C:\\Program Files"]; |
| 89 | const dirs = []; |
| 90 | for (const root of roots) { |
| 91 | if (!fs.existsSync(root)) continue; |
| 92 | for (const entry of fs.readdirSync(root, { withFileTypes: true })) { |
| 93 | if (!entry.isDirectory()) continue; |
| 94 | if (/^WiX Toolset v3(\.|$)/i.test(entry.name)) { |
| 95 | const bin = path.join(root, entry.name, "bin"); |
| 96 | if (fs.existsSync(bin)) dirs.push(bin); |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | return dirs; |
| 101 | } |
| 102 | |
| 103 | const WIX_PATHS = discoverWixBinDirs(); |
| 104 | |
| 105 | function wixTool(name) { |
| 106 | if (WIX_PATHS.length === 0) { |
| 107 | console.error( |
| 108 | "❌ No WiX Toolset v3.x installation found. Install from https://github.com/wixtoolset/wix3/releases", |
| 109 | ); |
| 110 | process.exit(1); |
| 111 | } |
| 112 | const found = findExe(WIX_PATHS.map((d) => path.join(d, name))); |
| 113 | if (!found) { |
| 114 | console.error(`❌ ${name} not found in: ${WIX_PATHS.join(", ")}`); |
| 115 | process.exit(1); |
| 116 | } |
| 117 | return found; |
| 118 | } |
| 119 | |
| 120 | function downloadArtifact(packageName, ver, targetDir) { |
| 121 | if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true }); |
| 122 | fs.mkdirSync(targetDir, { recursive: true }); |
| 123 | |
| 124 | const verArg = ver === "latest" ? "*" : ver; |
| 125 | runCommand("az", [ |
| 126 | "artifacts", |
| 127 | "universal", |
| 128 | "download", |
| 129 | "--organization", |
| 130 | "https://dev.azure.com/msctoproj", |
| 131 | "--project", |
| 132 | "AI_Systems", |
| 133 | "--scope", |
| 134 | "project", |
| 135 | "--feed", |
| 136 | "typeagent", |
| 137 | "--name", |
| 138 | packageName, |
| 139 | "--version", |
| 140 | verArg, |
| 141 | "--path", |
| 142 | targetDir, |
| 143 | ]); |
| 144 | |
| 145 | const files = fs.readdirSync(targetDir); |
| 146 | if (files.length === 0) { |
| 147 | console.error(`❌ Artifact download failed: ${targetDir} is empty`); |
| 148 | process.exit(1); |
| 149 | } |
| 150 | console.log(`✅ Downloaded ${packageName}: ${files.length} items`); |
| 151 | } |
| 152 | |
| 153 | // ── Step 1: Download artifacts ──────────────────────────────────────────────── |
| 154 | if (!skipDownload) { |
| 155 | console.log(`\n📥 Downloading agent-server.${rid}...`); |
| 156 | downloadArtifact(`agent-server.${rid}`, version, agentArtifactDir); |
| 157 | |
| 158 | console.log(`\n📥 Downloading typeagent-copilot-plugin...`); |
| 159 | downloadArtifact( |
| 160 | "typeagent-copilot-plugin", |
| 161 | pluginVersion, |
| 162 | pluginArtifactDir, |
| 163 | ); |
| 164 | } else { |
| 165 | for (const [label, dir] of [ |
| 166 | ["agent-server", agentArtifactDir], |
| 167 | ["copilot-plugin", pluginArtifactDir], |
| 168 | ]) { |
| 169 | if (!fs.existsSync(dir)) { |
| 170 | console.error( |
| 171 | `❌ --skip-download set but ${label} dir not found: ${dir}`, |
| 172 | ); |
| 173 | process.exit(1); |
| 174 | } |
| 175 | console.log(`⏭️ Skipping download, using: ${dir}`); |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // ── Step 2: Generate marketplace.json ───────────────────────────────────────── |
| 180 | console.log(`\n📝 Generating marketplace.json...`); |
| 181 | fs.mkdirSync(marketplaceDir, { recursive: true }); |
| 182 | const semverVersion = |
| 183 | version |
| 184 | .replace(/[^0-9.]/g, ".") |
| 185 | .replace(/\.{2,}/g, ".") |
| 186 | .replace(/\.$/, "") || "0.0.1"; |
| 187 | const marketplace = { |
| 188 | name: "typeagent-local", |
| 189 | owner: { name: "Microsoft", email: "typeagent@microsoft.com" }, |
| 190 | metadata: { |
| 191 | description: "TypeAgent Copilot CLI plugin", |
| 192 | version: semverVersion, |
| 193 | }, |
| 194 | plugins: [ |
| 195 | { |
| 196 | name: "typeagent", |
| 197 | description: "TypeAgent integration for Copilot CLI", |
| 198 | version: semverVersion, |
| 199 | source: "./copilot-plugin", |
| 200 | }, |
| 201 | ], |
| 202 | }; |
| 203 | fs.writeFileSync( |
| 204 | path.join(marketplaceDir, "marketplace.json"), |
| 205 | JSON.stringify(marketplace, null, 2), |
| 206 | ); |
| 207 | console.log(`✅ Generated marketplace.json`); |
| 208 | |
| 209 | // ── Step 3: Harvest file components with heat.exe ───────────────────────────── |
| 210 | const heatExe = wixTool("heat.exe"); |
| 211 | const candleExe = wixTool("candle.exe"); |
| 212 | const lightExe = wixTool("light.exe"); |
| 213 | |
| 214 | function runHeat(dir, componentGroup, dirRef, varName, outFile) { |
| 215 | console.log(`\n🔥 Harvesting ${componentGroup} from ${dir}...`); |
| 216 | runCommand(heatExe, [ |
| 217 | "dir", |
| 218 | dir, |
| 219 | "-cg", |
| 220 | componentGroup, |
| 221 | "-dr", |
| 222 | dirRef, |
| 223 | "-var", |
| 224 | `var.${varName}`, |
| 225 | "-gg", // generate stable GUIDs per file path |
| 226 | "-srd", // suppress root directory element |
| 227 | "-sfrag", // suppress fragment wrapping (use our own Product.wxs structure) |
| 228 | "-indent", |
| 229 | "2", |
| 230 | "-o", |
| 231 | outFile, |
| 232 | ]); |
| 233 | console.log(`✅ Harvested: ${outFile}`); |
| 234 | } |
| 235 | |
| 236 | runHeat( |
| 237 | agentArtifactDir, |
| 238 | "AgentServerComponents", |
| 239 | "INSTALLFOLDER", |
| 240 | "AgentServerArtifactDir", |
| 241 | agentHeatFile, |
| 242 | ); |
| 243 | runHeat( |
| 244 | pluginArtifactDir, |
| 245 | "CopilotPluginComponents", |
| 246 | "PLUGINFOLDER", |
| 247 | "CopilotPluginArtifactDir", |
| 248 | pluginHeatFile, |
| 249 | ); |
| 250 | |
| 251 | // ── Step 4: Compile WiX (candle.exe) ───────────────────────────────────────── |
| 252 | console.log(`\n🕯️ Compiling WiX...`); |
| 253 | |
| 254 | const wixobjDir = outputPath; |
| 255 | runCommand(candleExe, [ |
| 256 | `-dProductVersion=${version}`, |
| 257 | `-dAgentServerArtifactDir=${agentArtifactDir}`, |
| 258 | `-dCopilotPluginArtifactDir=${pluginArtifactDir}`, |
| 259 | `-dMarketplaceDir=${marketplaceDir}`, |
| 260 | `-dInstallerSourceDir=${wxsDir}`, |
| 261 | `-arch`, |
| 262 | `x64`, |
| 263 | `-o`, |
| 264 | `${wixobjDir}\\`, |
| 265 | wxsFile, |
| 266 | agentHeatFile, |
| 267 | pluginHeatFile, |
| 268 | ]); |
| 269 | console.log(`✅ Compiled WiX objects`); |
| 270 | |
| 271 | // ── Step 5: Link MSI (light.exe) ────────────────────────────────────────────── |
| 272 | console.log(`\n💡 Linking MSI...`); |
| 273 | |
| 274 | const msiName = `TypeAgent-${version}-${rid}.msi`; |
| 275 | const msiOutputPath = path.join(outputPath, msiName); |
| 276 | |
| 277 | runCommand(lightExe, [ |
| 278 | `-ext`, |
| 279 | `WixUIExtension`, |
| 280 | `-ext`, |
| 281 | `WixUtilExtension`, |
| 282 | `-cultures:en-us`, |
| 283 | `-o`, |
| 284 | msiOutputPath, |
| 285 | path.join(wixobjDir, "TypeAgent-AgentServer.wixobj"), |
| 286 | path.join(wixobjDir, "AgentServerFiles.wixobj"), |
| 287 | path.join(wixobjDir, "CopilotPluginFiles.wixobj"), |
| 288 | ]); |
| 289 | |
| 290 | if (!fs.existsSync(msiOutputPath)) { |
| 291 | console.error(`❌ MSI build failed: output file not created`); |
| 292 | process.exit(1); |
| 293 | } |
| 294 | |
| 295 | const sizeMb = (fs.statSync(msiOutputPath).size / 1024 / 1024).toFixed(1); |
| 296 | console.log(`\n✅ MSI build complete!`); |
| 297 | console.log(` Output: ${msiOutputPath} (${sizeMb} MB)`); |
| 298 | console.log(` Sign: node sign-msi.mjs "${msiOutputPath}"`); |
| 299 | |
| 300 | process.exit(0); |
| 301 | |