diff --git a/bin/archgate.cjs b/bin/archgate.cjs index 7aa158e2..b4110d01 100755 --- a/bin/archgate.cjs +++ b/bin/archgate.cjs @@ -2,6 +2,8 @@ "use strict"; const { execFileSync } = require("child_process"); +const https = require("https"); +const zlib = require("zlib"); const path = require("path"); const fs = require("fs"); @@ -15,9 +17,22 @@ function getPlatformPackageName() { ); } +function getBinaryName() { + return process.platform === "win32" ? "archgate.exe" : "archgate"; +} + +function getPackageVersion() { + const pkg = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8") + ); + return pkg.version; +} + function getBinaryPath() { const pkgName = getPlatformPackageName(); - const binaryName = process.platform === "win32" ? "archgate.exe" : "archgate"; + const binaryName = getBinaryName(); + + // 1. Try the platform-specific optional dependency (normal path). try { const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`)); const binaryPath = path.join(pkgDir, "bin", binaryName); @@ -25,16 +40,146 @@ function getBinaryPath() { } catch { /* platform package not installed */ } - throw new Error( - `archgate binary not found. "${getPlatformPackageName()}" may not be installed.\nTry reinstalling: npm install -g archgate` + + // 2. Fallback: binary downloaded into our own bin/ by postinstall or a + // previous on-demand download. + const fallbackPath = path.join(__dirname, binaryName); + if (fs.existsSync(fallbackPath)) return fallbackPath; + + return null; +} + +// --------------------------------------------------------------------------- +// On-demand download from the npm registry +// --------------------------------------------------------------------------- + +function fetchWithRedirects(url) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + fetchWithRedirects(res.headers.location).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`GET ${url} returned status ${res.statusCode}`)); + return; + } + resolve(res); + }) + .on("error", reject); + }); +} + +/** Strip null bytes from a buffer-decoded string. */ +function stripNulls(str) { + const idx = str.indexOf(String.fromCodePoint(0)); + return idx === -1 ? str : str.slice(0, idx); +} + +/** + * Download the platform-specific npm package tarball and extract the binary. + * Returns the path to the downloaded binary. + */ +async function downloadBinary() { + const pkgName = getPlatformPackageName(); + const version = getPackageVersion(); + const binaryName = getBinaryName(); + + const url = `https://registry.npmjs.org/${pkgName}/-/${pkgName}-${version}.tgz`; + console.error( + `archgate: binary not found, downloading ${pkgName}@${version}...` ); + + const res = await fetchWithRedirects(url); + + const binDir = __dirname; + const destPath = path.join(binDir, binaryName); + + return new Promise((resolve, reject) => { + const gunzip = zlib.createGunzip(); + const chunks = []; + const expectedSuffix = `bin/${binaryName}`; + let found = false; + + res.pipe(gunzip); + gunzip.on("data", (chunk) => chunks.push(chunk)); + gunzip.on("end", () => { + const data = Buffer.concat(chunks); + let offset = 0; + + while (offset + 512 <= data.length) { + const header = data.subarray(offset, offset + 512); + offset += 512; + + // Empty header block signals end of archive + if (header.every((b) => b === 0)) break; + + let name = stripNulls(header.subarray(0, 100).toString("utf8")); + const prefix = stripNulls(header.subarray(345, 500).toString("utf8")); + if (prefix) name = `${prefix}/${name}`; + + const sizeStr = stripNulls( + header.subarray(124, 136).toString("utf8") + ).trim(); + const size = parseInt(sizeStr, 8) || 0; + const blocks = Math.ceil(size / 512); + const fileData = data.subarray(offset, offset + size); + offset += blocks * 512; + + if (name.endsWith(expectedSuffix)) { + fs.writeFileSync(destPath, fileData, { mode: 0o755 }); + found = true; + break; + } + } + + if (found) { + console.error(`archgate: binary downloaded successfully.`); + resolve(destPath); + } else { + reject( + new Error(`Could not find ${expectedSuffix} in tarball from ${url}`) + ); + } + }); + + gunzip.on("error", reject); + }); } -try { - const binary = getBinaryPath(); - execFileSync(binary, process.argv.slice(2), { stdio: "inherit" }); -} catch (e) { - if (typeof e.status === "number") process.exit(e.status); - console.error(e.message); - process.exit(2); +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + let binary = getBinaryPath(); + + // If the binary is missing (optional dep skipped AND postinstall blocked), + // download it on-demand from the npm registry. + if (!binary) { + try { + binary = await downloadBinary(); + } catch (err) { + console.error( + `archgate: failed to download binary: ${err.message}\n` + + `Try reinstalling: npm install archgate` + ); + process.exit(2); + } + } + + try { + execFileSync(binary, process.argv.slice(2), { stdio: "inherit" }); + } catch (e) { + if (typeof e.status === "number") process.exit(e.status); + console.error(e.message); + process.exit(2); + } } + +main(); diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index f4e9b679..9ae020b0 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -15,6 +15,10 @@ export default defineConfig({ "Enforce Architecture Decision Records as executable rules — for both humans and AI agents.", customCss: ["./src/styles/custom.css"], expressiveCode: { + // Use the pure-JS regex engine instead of the WASM-based oniguruma + // engine. Bun's WASM support on Cloudflare Pages triggers a + // "call_indirect to a null table entry" crash in oniguruma. + shiki: { engine: "javascript" }, // Disable Starlight's automatic UI color overrides so our // hand-picked colors from archgate.dev take full effect. useStarlightUiThemeColors: false, diff --git a/package.json b/package.json index c63e3e91..5c4143f3 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "archgate": "bin/archgate.cjs" }, "files": [ - "bin/archgate.cjs" + "bin/archgate.cjs", + "scripts/postinstall.cjs" ], "os": [ "darwin", @@ -47,6 +48,7 @@ "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --deny-warnings .", + "postinstall": "node scripts/postinstall.cjs", "test": "bun test --timeout 60000", "test:watch": "bun test --watch --timeout 60000", "typecheck": "tsc --build", diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs new file mode 100644 index 00000000..ef375ee2 --- /dev/null +++ b/scripts/postinstall.cjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +"use strict"; + +// Best-effort pre-download: tries to fetch the binary during install so the +// first `archgate` invocation is instant. If this script is blocked by the +// package manager (--ignore-scripts, etc.), the bin shim will download +// on-demand at runtime instead. + +const path = require("path"); +const fs = require("fs"); + +const PACKAGE_NAME_MAP = { + "darwin-arm64": "archgate-darwin-arm64", + "linux-x64": "archgate-linux-x64", + "win32-x64": "archgate-win32-x64", +}; + +function isBinaryPresent() { + const key = `${process.platform}-${process.arch}`; + const pkgName = PACKAGE_NAME_MAP[key]; + if (!pkgName) return true; // unsupported platform — skip silently + + const binaryName = process.platform === "win32" ? "archgate.exe" : "archgate"; + + // Check optional dependency + try { + const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`)); + if (fs.existsSync(path.join(pkgDir, "bin", binaryName))) return true; + } catch { + /* not installed */ + } + + // Check local fallback + if (fs.existsSync(path.join(__dirname, "..", "bin", binaryName))) return true; + + return false; +} + +if (!isBinaryPresent()) { + // Delegate to the bin shim's download logic by running it with --version. + // This triggers the on-demand download without side effects. + try { + require("child_process").execFileSync( + process.execPath, + [path.join(__dirname, "..", "bin", "archgate.cjs"), "--version"], + { stdio: "inherit" } + ); + } catch { + // Postinstall must never block `npm install`. + console.warn( + "archgate: could not pre-download binary. It will be downloaded on first run." + ); + } +}