From 7477bc13e92df13075f16f378e4706a487d3c339 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 16:21:04 -0600 Subject: [PATCH 01/10] Implement standalone executables --- AGENTS.md | 5 + CLAUDE.md | 1 + docs/SEA-BUILD-SIGNING.md | 137 +++++ helpers/build-sea.js | 167 ++++++ npm-shrinkwrap.json | 483 ++++++++++++++++++ package.json | 3 + src/bin/vip-sea.js | 22 + src/bin/vip.js | 216 +++++--- src/commands/dev-env-sync-sql.ts | 2 +- src/lib/cli/command.js | 18 +- src/lib/cli/config.ts | 16 +- src/lib/cli/exit.ts | 3 +- src/lib/cli/internal-bin-loader.js | 79 +++ src/lib/cli/runtime-mode.ts | 20 + src/lib/cli/sea-dispatch.js | 96 ++++ src/lib/cli/sea-runtime.js | 84 +++ .../dev-environment/dev-environment-cli.ts | 28 +- .../dev-environment/dev-environment-core.ts | 69 ++- .../dev-environment/dev-environment-lando.ts | 91 +++- src/lib/dev-environment/lando-loader.ts | 54 ++ 20 files changed, 1479 insertions(+), 115 deletions(-) create mode 100644 docs/SEA-BUILD-SIGNING.md create mode 100644 helpers/build-sea.js create mode 100644 src/bin/vip-sea.js create mode 100644 src/lib/cli/internal-bin-loader.js create mode 100644 src/lib/cli/runtime-mode.ts create mode 100644 src/lib/cli/sea-dispatch.js create mode 100644 src/lib/cli/sea-runtime.js create mode 100644 src/lib/dev-environment/lando-loader.ts diff --git a/AGENTS.md b/AGENTS.md index 448c3762c..6ba0f2218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,11 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting - `prepare` runs `npm run clean && npm run build`; npm package bins point to `dist/**`. Always rebuild before publishing so dist matches src. - `helpers/prepublishOnly.js` enforces branch `trunk` for `npm publish --tag latest` and optionally reruns `npm test`. Release flows expect a clean node version that satisfies `engines.node`. +## Standalone SEA Packaging +- Canonical runbook for standalone executable build/signing is in `docs/SEA-BUILD-SIGNING.md`. Use it for macOS, Linux, Windows native, and WSL-mediated Windows builds. +- SEA builds are Node 22 only (enforced in `helpers/build-sea.js`); always verify `node -v` before `npm run build:sea`. +- The executable is self-contained for Node runtime + JS deps, but `dev-env` commands still require host Docker/Compose availability. + ## Common Pitfalls Checklist - Running CLI without a token opens a browser (`open`) and waits for interactive input—pass `--help` or set `WPVIP_DEPLOY_TOKEN` in automation. - Forgetting `--app/--env` or alias when a command expects them triggers extra GraphQL lookups and prompts; in headless contexts set `_opts.appContext=false` or supply explicit flags. diff --git a/CLAUDE.md b/CLAUDE.md index 1e7b1d4e8..3edb3d317 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,4 @@ # CLAUDE For guidance on working in this repo, traps, and migration notes, see `AGENTS.md`. +For standalone SEA build/signing by platform, see `docs/SEA-BUILD-SIGNING.md`. diff --git a/docs/SEA-BUILD-SIGNING.md b/docs/SEA-BUILD-SIGNING.md new file mode 100644 index 000000000..9b0509f33 --- /dev/null +++ b/docs/SEA-BUILD-SIGNING.md @@ -0,0 +1,137 @@ +# SEA Build and Signing Runbook + +Purpose: build and sign the standalone VIP CLI executable (`dist/sea/vip` or `dist/sea/vip.exe`) for each supported platform. + +This repo uses `helpers/build-sea.js` and the `npm run build:sea` script. SEA build is pinned to Node 22. + +## Shared Prerequisites +- Use Node 22.x exactly for SEA builds. +- Install dependencies before building: `npm ci`. +- Build from repo root. +- The build script embeds: + - Node runtime + - bundled CLI code (`src/bin/vip-sea.js`) + - SEA assets and runtime dependency archive (`sea.node_modules.tgz`) +- Output paths: + - Unix: `dist/sea/vip` + - Windows: `dist/sea/vip.exe` + +## Shared Build Steps +```bash +npm ci +npm run build +npm run build:sea +``` + +Quick smoke checks after every build: +```bash +dist/sea/vip --version +dist/sea/vip whoami --help +dist/sea/vip dev-env info --help +``` + +## macOS (native) +Node/tool setup: +```bash +export NVM_DIR="$HOME/.nvm" +. "$NVM_DIR/nvm.sh" +nvm use 22 +node -v +``` + +Build: +```bash +npm ci +npm run build +npm run build:sea +``` + +Notes: +- `helpers/build-sea.js` already does ad-hoc signing (`codesign --sign -`) after blob injection so local execution works. +- For distribution, replace ad-hoc signature with a real Developer ID certificate. + +Distribution signing: +```bash +codesign --remove-signature dist/sea/vip +codesign --sign "Developer ID Application: " --force --options runtime dist/sea/vip +codesign --verify --strict --verbose=2 dist/sea/vip +spctl -a -t exec -vv dist/sea/vip +``` + +## Linux (native) +Node/tool setup: +```bash +node -v +``` +(Use Node 22 before build.) + +Build: +```bash +npm ci +npm run build +npm run build:sea +chmod +x dist/sea/vip +``` + +Signing guidance: +- Linux does not have a universal OS-enforced Authenticode-style executable signature. +- Recommended: publish checksums and detached signatures. + +Checksum + GPG example: +```bash +sha256sum dist/sea/vip > dist/sea/vip.sha256 +gpg --armor --detach-sign dist/sea/vip +``` + +Cosign blob example: +```bash +cosign sign-blob --yes --output-signature dist/sea/vip.sig dist/sea/vip +cosign verify-blob --signature dist/sea/vip.sig dist/sea/vip +``` + +## Windows (native) +Use PowerShell or `cmd.exe` on Windows (not WSL) when producing Windows artifacts. + +Build: +```powershell +npm ci +npm run build +npm run build:sea +.\dist\sea\vip.exe --version +``` + +Authenticode signing (SignTool): +```powershell +signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /a .\dist\sea\vip.exe +signtool verify /pa /v .\dist\sea\vip.exe +``` + +If your cert is in a PFX file: +```powershell +signtool sign /f C:\path\cert.pfx /p /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com .\dist\sea\vip.exe +``` + +## Windows from WSL +Important: WSL builds Linux binaries by default. + +- If target is Linux binary: build/sign inside WSL using the Linux flow. +- If target is Windows `.exe`: run the build and signing commands in Windows context. + +From WSL, invoke Windows PowerShell for a Windows-target build: +```bash +WIN_REPO_PATH="$(wslpath -w "$PWD")" +powershell.exe -NoProfile -Command "Set-Location '$WIN_REPO_PATH'; npm ci; npm run build; npm run build:sea" +``` + +Then sign in Windows context: +```bash +powershell.exe -NoProfile -Command "Set-Location '$WIN_REPO_PATH'; signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /a .\\dist\\sea\\vip.exe; signtool verify /pa /v .\\dist\\sea\\vip.exe" +``` + +## Release Checklist for Agents +- Confirm Node 22 before SEA build. +- Confirm artifact type matches target OS (`vip` vs `vip.exe`). +- Run smoke checks on the produced executable. +- Apply platform-appropriate signature method. +- Verify signature/checksum before publishing. +- Record signing method and timestamp authority in release notes. diff --git a/helpers/build-sea.js b/helpers/build-sea.js new file mode 100644 index 000000000..54159ab20 --- /dev/null +++ b/helpers/build-sea.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +const { buildSync } = require( 'esbuild' ); +const { spawnSync } = require( 'node:child_process' ); +const { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } = require( 'node:fs' ); +const path = require( 'node:path' ); +const tar = require( 'tar' ); + +const SEA_FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; + +const projectRoot = path.resolve( __dirname, '..' ); +const seaDir = path.join( projectRoot, 'dist', 'sea' ); +const bundlePath = path.join( seaDir, 'vip.bundle.cjs' ); +const blobPath = path.join( seaDir, 'vip.blob' ); +const seaConfigPath = path.join( seaDir, 'sea-config.json' ); +const executablePath = path.join( seaDir, process.platform === 'win32' ? 'vip.exe' : 'vip' ); +const nodeModulesArchivePath = path.join( seaDir, 'node_modules.tgz' ); + +function run( command, args, options = {} ) { + const result = spawnSync( command, args, { + cwd: projectRoot, + stdio: 'inherit', + ...options, + } ); + + if ( result.status !== 0 ) { + process.exit( result.status || 1 ); + } +} + +function ensureNode22() { + const major = Number( process.versions.node.split( '.' )[ 0 ] ); + if ( major !== 22 ) { + console.error( + `Error: SEA build requires Node 22.x. Current version is ${ process.versions.node }.` + ); + process.exit( 1 ); + } +} + +async function createRuntimeArchive() { + await tar.c( + { + cwd: projectRoot, + file: nodeModulesArchivePath, + gzip: true, + portable: true, + noMtime: true, + }, + [ 'node_modules' ] + ); +} + +function writeSeaConfig() { + const config = { + main: bundlePath, + output: blobPath, + disableExperimentalSEAWarning: true, + assets: { + 'dev-env.lando.template.yml.ejs': path.join( + projectRoot, + 'assets', + 'dev-env.lando.template.yml.ejs' + ), + 'dev-env.nginx.template.conf.ejs': path.join( + projectRoot, + 'assets', + 'dev-env.nginx.template.conf.ejs' + ), + 'sea.node_modules.tgz': nodeModulesArchivePath, + }, + }; + + writeFileSync( seaConfigPath, `${ JSON.stringify( config, null, 2 ) }\n`, 'utf8' ); +} + +function buildBundle() { + buildSync( { + entryPoints: [ path.join( projectRoot, 'src', 'bin', 'vip-sea.js' ) ], + bundle: true, + platform: 'node', + target: 'node22', + format: 'cjs', + outfile: bundlePath, + external: [ + '@postman/node-keytar', + '@postman/node-keytar/*', + 'cpu-features', + 'cpu-features/*', + 'lando', + 'lando/*', + 'ssh2', + 'ssh2/*', + '*.node', + ], + } ); +} + +function stripBundleShebang() { + const bundleContent = readFileSync( bundlePath, 'utf8' ); + if ( ! bundleContent.startsWith( '#!' ) ) { + return; + } + + writeFileSync( bundlePath, bundleContent.replace( /^#![^\n]*\n/, '' ), 'utf8' ); +} + +function buildBlob() { + run( process.execPath, [ '--experimental-sea-config', seaConfigPath ] ); +} + +function prepareExecutable() { + copyFileSync( process.execPath, executablePath ); + + if ( process.platform === 'darwin' ) { + run( 'codesign', [ '--remove-signature', executablePath ] ); + } +} + +function injectBlob() { + const postjectCli = require.resolve( 'postject/dist/cli.js' ); + const args = [ + postjectCli, + executablePath, + 'NODE_SEA_BLOB', + blobPath, + '--sentinel-fuse', + SEA_FUSE, + ]; + + if ( process.platform === 'darwin' ) { + args.push( '--macho-segment-name', 'NODE_SEA' ); + } + + if ( process.platform === 'windows' ) { + args.push( '--overwrite' ); + } + + run( process.execPath, args ); +} + +function finalizeExecutable() { + if ( process.platform === 'darwin' ) { + run( 'codesign', [ '--sign', '-', '--force', executablePath ] ); + } + + if ( process.platform !== 'win32' ) { + chmodSync( executablePath, 0o755 ); + } +} + +async function main() { + ensureNode22(); + mkdirSync( seaDir, { recursive: true } ); + await createRuntimeArchive(); + writeSeaConfig(); + buildBundle(); + stripBundleShebang(); + buildBlob(); + prepareExecutable(); + injectBlob(); + finalizeExecutable(); + + console.log( `SEA executable written to ${ executablePath }` ); +} + +void main(); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 95238e011..5c506c4a1 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -129,8 +129,10 @@ "@types/tar": "^6.1.13", "@types/xml2js": "^0.4.14", "dockerode": "^4.0.0", + "esbuild": "^0.27.3", "jest": "^30.0.0", "nock": "13.5.6", + "postject": "^1.0.0-alpha.6", "prettier": "npm:wp-prettier@3.0.3", "typescript": "^5.2.2" }, @@ -2223,6 +2225,422 @@ "node": ">=10" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -7003,6 +7421,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -12476,6 +12935,30 @@ "node": ">= 0.4" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", diff --git a/package.json b/package.json index c24b31e3b..efbbcb6e2 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "check-types": "tsc", "postinstall": "node ./helpers/check-version.js", "build": "babel src -d dist --extensions=\".js,.ts\"", + "build:sea": "node ./helpers/build-sea.js", "build:watch": "babel src -d dist --watch --source-maps --extensions=\".js,.ts\"", "format": "npm run cmd:format -- --write", "format:check": "npm run cmd:format -- --check", @@ -132,8 +133,10 @@ "@types/tar": "^6.1.13", "@types/xml2js": "^0.4.14", "dockerode": "^4.0.0", + "esbuild": "^0.27.3", "jest": "^30.0.0", "nock": "13.5.6", + "postject": "^1.0.0-alpha.6", "prettier": "npm:wp-prettier@3.0.3", "typescript": "^5.2.2" }, diff --git a/src/bin/vip-sea.js b/src/bin/vip-sea.js new file mode 100644 index 000000000..0a92dc715 --- /dev/null +++ b/src/bin/vip-sea.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { resolveInternalBinFromArgv, isSeaRuntime } from '../lib/cli/sea-dispatch'; +import { prepareSeaRuntimeFilesystem } from '../lib/cli/sea-runtime'; + +const run = async () => { + if ( isSeaRuntime() ) { + process.env.VIP_CLI_SEA_MODE = '1'; + await prepareSeaRuntimeFilesystem(); + + const resolution = resolveInternalBinFromArgv( process.argv ); + if ( resolution.bin !== 'vip' ) { + process.env.VIP_CLI_TARGET_BIN = resolution.bin; + process.env.VIP_CLI_TARGET_START = String( resolution.start ); + process.env.VIP_CLI_TARGET_LENGTH = String( resolution.length ); + } + } + + await import( './vip.js' ); +}; + +void run(); diff --git a/src/bin/vip.js b/src/bin/vip.js index 21388c44f..ee61a4c91 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -8,6 +8,12 @@ import { prompt } from 'enquirer'; import command, { containsAppEnvArgument } from '../lib/cli/command'; import config from '../lib/cli/config'; +import { loadInternalBin } from '../lib/cli/internal-bin-loader'; +import { + rewriteArgvForInternalBin, + resolveInternalBinFromArgv, + isSeaRuntime, +} from '../lib/cli/sea-dispatch'; import Token from '../lib/token'; import { aliasUser, trackEvent } from '../lib/tracker'; @@ -24,6 +30,28 @@ if ( config && config.environment !== 'production' ) { const tokenURL = 'https://dashboard.wpvip.com/me/cli/token'; const customDeployToken = process.env.WPVIP_DEPLOY_TOKEN; +async function maybeExecuteSeaTargetCommand() { + const targetBin = process.env.VIP_CLI_TARGET_BIN; + if ( ! isSeaRuntime() || ! targetBin || targetBin === 'vip' ) { + return false; + } + + const start = Number( process.env.VIP_CLI_TARGET_START ?? '0' ); + const length = Number( process.env.VIP_CLI_TARGET_LENGTH ?? '0' ); + const resolution = { + start: Number.isInteger( start ) ? start : 0, + length: Number.isInteger( length ) ? length : 0, + }; + + process.argv = rewriteArgvForInternalBin( process.argv, resolution ); + const loaded = await loadInternalBin( targetBin ); + if ( ! loaded ) { + throw new Error( `Unable to load SEA command target "${ targetBin }"` ); + } + + return true; +} + const runCmd = async function () { const cmd = command(); cmd @@ -61,7 +89,104 @@ function doesArgvHaveAtLeastOneParam( argv, params ) { return argv.some( arg => params.includes( arg ) ); } +async function runLoginFlow() { + console.log(); + console.log( ' _ __ ________ ________ ____' ); + console.log( ' | | / // _/ __ / ____/ / / _/' ); + console.log( ' | | / / / // /_/ /______/ / / / / / ' ); + console.log( ' | |/ /_/ // ____//_____/ /___/ /____/ / ' ); + console.log( ' |___//___/_/ ____/_____/___/ ' ); + console.log(); + console.log( ' VIP-CLI is your tool for interacting with and managing your VIP applications.' ); + console.log(); + + console.log( + ' Authenticate your installation of VIP-CLI with your Personal Access Token. This URL will be opened in your web browser automatically so that you can retrieve your token: ' + + tokenURL + ); + console.log(); + + await trackEvent( 'login_command_execute' ); + + const answer = await prompt( { + type: 'confirm', + name: 'continue', + message: 'Ready to authenticate?', + } ); + + if ( ! answer.continue ) { + await trackEvent( 'login_command_browser_cancelled' ); + + return null; + } + + const { default: open } = await import( 'open' ); + + open( tokenURL, { wait: false } ); + + await trackEvent( 'login_command_browser_opened' ); + + const { token: tokenInput } = await prompt( { + type: 'password', + name: 'token', + message: 'Access Token:', + } ); + + let token; + try { + token = new Token( tokenInput ); + } catch ( err ) { + console.log( 'The token provided is malformed. Please check the token and try again.' ); + + await trackEvent( 'login_command_token_submit_error', { error: err.message } ); + + return null; + } + + if ( token.expired() ) { + console.log( 'The token provided is expired. Please log in again to refresh the token.' ); + + await trackEvent( 'login_command_token_submit_error', { error: 'expired' } ); + + return null; + } + + if ( ! token.valid() ) { + console.log( 'The provided token is not valid. Please log in again to refresh the token.' ); + + await trackEvent( 'login_command_token_submit_error', { error: 'invalid' } ); + + return null; + } + + try { + await Token.set( token.raw ); + } catch ( err ) { + await trackEvent( 'login_command_token_submit_error', { + error: err.message, + } ); + + throw err; + } + + // De-anonymize user for tracking + await aliasUser( token.id ); + + await trackEvent( 'login_command_token_submit_success' ); + + return token; +} + const rootCmd = async function () { + if ( isSeaRuntime() && ! process.env.VIP_CLI_TARGET_BIN ) { + const resolution = resolveInternalBinFromArgv( process.argv ); + if ( resolution.bin !== 'vip' ) { + process.env.VIP_CLI_TARGET_BIN = resolution.bin; + process.env.VIP_CLI_TARGET_START = String( resolution.start ); + process.env.VIP_CLI_TARGET_LENGTH = String( resolution.length ); + } + } + let token = await Token.get(); const isHelpCommand = doesArgvHaveAtLeastOneParam( process.argv, [ 'help', '-h', '--help' ] ); @@ -85,99 +210,26 @@ const rootCmd = async function () { token?.valid() || isCustomDeployCmdWithKey ) ) { - await runCmd(); - } else { - console.log(); - console.log( ' _ __ ________ ________ ____' ); - console.log( ' | | / // _/ __ / ____/ / / _/' ); - console.log( ' | | / / / // /_/ /______/ / / / / / ' ); - console.log( ' | |/ /_/ // ____//_____/ /___/ /____/ / ' ); - console.log( ' |___//___/_/ ____/_____/___/ ' ); - console.log(); - console.log( - ' VIP-CLI is your tool for interacting with and managing your VIP applications.' - ); - console.log(); - - console.log( - ' Authenticate your installation of VIP-CLI with your Personal Access Token. This URL will be opened in your web browser automatically so that you can retrieve your token: ' + - tokenURL - ); - console.log(); - - await trackEvent( 'login_command_execute' ); - - const answer = await prompt( { - type: 'confirm', - name: 'continue', - message: 'Ready to authenticate?', - } ); - - if ( ! answer.continue ) { - await trackEvent( 'login_command_browser_cancelled' ); - - return; - } - - const { default: open } = await import( 'open' ); - - open( tokenURL, { wait: false } ); - - await trackEvent( 'login_command_browser_opened' ); - - const { token: tokenInput } = await prompt( { - type: 'password', - name: 'token', - message: 'Access Token:', - } ); - - try { - token = new Token( tokenInput ); - } catch ( err ) { - console.log( 'The token provided is malformed. Please check the token and try again.' ); - - await trackEvent( 'login_command_token_submit_error', { error: err.message } ); - + if ( await maybeExecuteSeaTargetCommand() ) { return; } - - if ( token.expired() ) { - console.log( 'The token provided is expired. Please log in again to refresh the token.' ); - - await trackEvent( 'login_command_token_submit_error', { error: 'expired' } ); - - return; - } - - if ( ! token.valid() ) { - console.log( 'The provided token is not valid. Please log in again to refresh the token.' ); - - await trackEvent( 'login_command_token_submit_error', { error: 'invalid' } ); - + await runCmd(); + } else { + token = await runLoginFlow(); + if ( ! token ) { return; } - try { - await Token.set( token.raw ); - } catch ( err ) { - await trackEvent( 'login_command_token_submit_error', { - error: err.message, - } ); - - throw err; - } - - // De-anonymize user for tracking - await aliasUser( token.id ); - - await trackEvent( 'login_command_token_submit_success' ); - if ( isLoginCommand ) { console.log( 'You are now logged in - see `vip -h` for a list of available commands.' ); process.exit(); } + if ( await maybeExecuteSeaTargetCommand() ) { + return; + } + await runCmd(); } }; diff --git a/src/commands/dev-env-sync-sql.ts b/src/commands/dev-env-sync-sql.ts index 59eb8adc9..cfeea912f 100644 --- a/src/commands/dev-env-sync-sql.ts +++ b/src/commands/dev-env-sync-sql.ts @@ -6,7 +6,7 @@ import chalk from 'chalk'; import debugLib from 'debug'; import fs from 'fs'; import gql from 'graphql-tag'; -import Lando from 'lando'; +import type Lando from 'lando'; import { pipeline } from 'node:stream/promises'; import { DevEnvImportSQLCommand, DevEnvImportSQLOptions } from './dev-env-import-sql'; diff --git a/src/lib/cli/command.js b/src/lib/cli/command.js index 765b1eadd..a3afdea7d 100644 --- a/src/lib/cli/command.js +++ b/src/lib/cli/command.js @@ -10,6 +10,7 @@ import path from 'node:path'; import { isAlias, parseEnvAliasFromArgv } from './envAlias'; import * as exit from './exit'; import { formatData, formatSearchReplaceValues } from './format'; +import { hasInternalBin, loadInternalBin } from './internal-bin-loader'; import { confirm } from './prompt'; import pkg from '../../../package.json'; import API from '../../lib/api'; @@ -233,7 +234,7 @@ class CommanderArgsCompat { return this.program.opts(); } - executeSubcommand( argv, parsedAlias, subcommand ) { + async executeSubcommand( argv, parsedAlias, subcommand ) { const currentScript = argv[ 1 ]; const extension = path.extname( currentScript ); const baseScriptPath = extension ? currentScript.slice( 0, -extension.length ) : currentScript; @@ -259,6 +260,17 @@ class CommanderArgsCompat { } ); } else { const fallbackCommand = `${ path.basename( baseScriptPath ) }-${ subcommand }`; + + if ( process.env.VIP_CLI_SEA_MODE === '1' && hasInternalBin( fallbackCommand ) ) { + process.argv = [ process.argv[ 0 ], process.argv[ 1 ], ...childArgs ]; + const loaded = await loadInternalBin( fallbackCommand ); + if ( ! loaded ) { + throw new Error( `Unable to load SEA subcommand "${ fallbackCommand }"` ); + } + + return; + } + runResult = spawnSync( fallbackCommand, childArgs, { stdio: 'inherit', env: process.env, @@ -293,7 +305,7 @@ CommanderArgsCompat.prototype.argv = async function ( argv, cb ) { // If there's a sub-command, run that instead if ( this.isDefined( this.sub[ 0 ], 'commands' ) ) { - this.executeSubcommand( argv, parsedAlias, this.sub[ 0 ] ); + await this.executeSubcommand( argv, parsedAlias, this.sub[ 0 ] ); return {}; } @@ -333,7 +345,7 @@ CommanderArgsCompat.prototype.argv = async function ( argv, cb ) { exit.withError( error ); } - if ( process.env.NODE_ENV !== 'test' ) { + if ( process.env.NODE_ENV !== 'test' && process.env.VIP_CLI_SEA_MODE !== '1' ) { const { default: updateNotifier } = await import( 'update-notifier' ); updateNotifier( { pkg, updateCheckInterval: 1000 * 60 * 60 * 24 } ).notify( { isGlobal: true, diff --git a/src/lib/cli/config.ts b/src/lib/cli/config.ts index 4c38e36f3..4f5f5cbea 100644 --- a/src/lib/cli/config.ts +++ b/src/lib/cli/config.ts @@ -2,6 +2,8 @@ import debugLib from 'debug'; import { readFileSync } from 'node:fs'; // I don't like using synchronous versions, but until we migrate to ESM, we have to. import path from 'node:path'; +import defaultPublishConfig from '../../../config/config.publish.json'; + interface Config { tracksUserType: string; tracksAnonUserType: string; @@ -11,7 +13,7 @@ interface Config { const debug = debugLib( '@automattic/vip:lib:cli:config' ); -export function loadConfigFile(): Config | null { +export function loadConfigFile(): Config { const paths = [ // Get `local` config first; this will only exist in dev as it's npmignore-d. path.join( __dirname, '../../../config/config.local.json' ), @@ -30,16 +32,8 @@ export function loadConfigFile(): Config | null { } } - return null; + return defaultPublishConfig as Config; } const configFromFile = loadConfigFile(); -if ( null === configFromFile ) { - // This should not happen because `config/config.publish.json` is always present. - console.error( 'FATAL ERROR: Could not find a valid configuration file' ); - process.exit( 1 ); -} - -// Without this, TypeScript will export `configFromFile` as `Config | null` -const exportedConfig: Config = configFromFile; -export default exportedConfig; +export default configFromFile; diff --git a/src/lib/cli/exit.ts b/src/lib/cli/exit.ts index 04e5f558e..a9f86ec8e 100644 --- a/src/lib/cli/exit.ts +++ b/src/lib/cli/exit.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import debug from 'debug'; import env from '../../lib/env'; +import { getRuntimeModeLabel } from './runtime-mode'; export function withError( message: Error | string ): never { const msg = message instanceof Error ? message.message : message; @@ -13,7 +14,7 @@ export function withError( message: Error | string ): never { console.log( `${ chalk.yellow( 'Debug: ' ) } VIP-CLI v${ env.app.version }, Node ${ env.node.version }, ${ env.os.name - } ${ env.os.version } ${ env.os.arch }` + } ${ env.os.version } ${ env.os.arch }, Runtime ${ getRuntimeModeLabel() }` ); if ( debug.names.length > 0 && message instanceof Error ) { diff --git a/src/lib/cli/internal-bin-loader.js b/src/lib/cli/internal-bin-loader.js new file mode 100644 index 000000000..639414f4d --- /dev/null +++ b/src/lib/cli/internal-bin-loader.js @@ -0,0 +1,79 @@ +const internalBinLoaders = { + vip: () => import( '../../bin/vip' ), + 'vip-app': () => import( '../../bin/vip-app' ), + 'vip-app-deploy': () => import( '../../bin/vip-app-deploy' ), + 'vip-app-deploy-validate': () => import( '../../bin/vip-app-deploy-validate' ), + 'vip-app-list': () => import( '../../bin/vip-app-list' ), + 'vip-backup': () => import( '../../bin/vip-backup' ), + 'vip-backup-db': () => import( '../../bin/vip-backup-db' ), + 'vip-cache': () => import( '../../bin/vip-cache' ), + 'vip-cache-purge-url': () => import( '../../bin/vip-cache-purge-url' ), + 'vip-config': () => import( '../../bin/vip-config' ), + 'vip-config-envvar': () => import( '../../bin/vip-config-envvar' ), + 'vip-config-envvar-delete': () => import( '../../bin/vip-config-envvar-delete' ), + 'vip-config-envvar-get': () => import( '../../bin/vip-config-envvar-get' ), + 'vip-config-envvar-get-all': () => import( '../../bin/vip-config-envvar-get-all' ), + 'vip-config-envvar-list': () => import( '../../bin/vip-config-envvar-list' ), + 'vip-config-envvar-set': () => import( '../../bin/vip-config-envvar-set' ), + 'vip-config-software': () => import( '../../bin/vip-config-software' ), + 'vip-config-software-get': () => import( '../../bin/vip-config-software-get' ), + 'vip-config-software-update': () => import( '../../bin/vip-config-software-update' ), + 'vip-db': () => import( '../../bin/vip-db' ), + 'vip-db-phpmyadmin': () => import( '../../bin/vip-db-phpmyadmin' ), + 'vip-dev-env': () => import( '../../bin/vip-dev-env' ), + 'vip-dev-env-create': () => import( '../../bin/vip-dev-env-create' ), + 'vip-dev-env-destroy': () => import( '../../bin/vip-dev-env-destroy' ), + 'vip-dev-env-envvar': () => import( '../../bin/vip-dev-env-envvar' ), + 'vip-dev-env-envvar-delete': () => import( '../../bin/vip-dev-env-envvar-delete' ), + 'vip-dev-env-envvar-get': () => import( '../../bin/vip-dev-env-envvar-get' ), + 'vip-dev-env-envvar-get-all': () => import( '../../bin/vip-dev-env-envvar-get-all' ), + 'vip-dev-env-envvar-list': () => import( '../../bin/vip-dev-env-envvar-list' ), + 'vip-dev-env-envvar-set': () => import( '../../bin/vip-dev-env-envvar-set' ), + 'vip-dev-env-exec': () => import( '../../bin/vip-dev-env-exec' ), + 'vip-dev-env-import': () => import( '../../bin/vip-dev-env-import' ), + 'vip-dev-env-import-media': () => import( '../../bin/vip-dev-env-import-media' ), + 'vip-dev-env-import-sql': () => import( '../../bin/vip-dev-env-import-sql' ), + 'vip-dev-env-info': () => import( '../../bin/vip-dev-env-info' ), + 'vip-dev-env-list': () => import( '../../bin/vip-dev-env-list' ), + 'vip-dev-env-logs': () => import( '../../bin/vip-dev-env-logs' ), + 'vip-dev-env-purge': () => import( '../../bin/vip-dev-env-purge' ), + 'vip-dev-env-shell': () => import( '../../bin/vip-dev-env-shell' ), + 'vip-dev-env-start': () => import( '../../bin/vip-dev-env-start' ), + 'vip-dev-env-stop': () => import( '../../bin/vip-dev-env-stop' ), + 'vip-dev-env-sync': () => import( '../../bin/vip-dev-env-sync' ), + 'vip-dev-env-sync-sql': () => import( '../../bin/vip-dev-env-sync-sql' ), + 'vip-dev-env-update': () => import( '../../bin/vip-dev-env-update' ), + 'vip-export': () => import( '../../bin/vip-export' ), + 'vip-export-sql': () => import( '../../bin/vip-export-sql' ), + 'vip-import': () => import( '../../bin/vip-import' ), + 'vip-import-media': () => import( '../../bin/vip-import-media' ), + 'vip-import-media-abort': () => import( '../../bin/vip-import-media-abort' ), + 'vip-import-media-status': () => import( '../../bin/vip-import-media-status' ), + 'vip-import-sql': () => import( '../../bin/vip-import-sql' ), + 'vip-import-sql-status': () => import( '../../bin/vip-import-sql-status' ), + 'vip-import-validate-files': () => import( '../../bin/vip-import-validate-files' ), + 'vip-import-validate-sql': () => import( '../../bin/vip-import-validate-sql' ), + 'vip-logout': () => import( '../../bin/vip-logout' ), + 'vip-logs': () => import( '../../bin/vip-logs' ), + 'vip-search-replace': () => import( '../../bin/vip-search-replace' ), + 'vip-slowlogs': () => import( '../../bin/vip-slowlogs' ), + 'vip-sync': () => import( '../../bin/vip-sync' ), + 'vip-whoami': () => import( '../../bin/vip-whoami' ), + 'vip-wp': () => import( '../../bin/vip-wp' ), +}; + +export const internalBinNames = Object.freeze( Object.keys( internalBinLoaders ) ); + +export async function loadInternalBin( binName ) { + const loader = internalBinLoaders[ binName ]; + if ( ! loader ) { + return false; + } + + await loader(); + return true; +} + +export function hasInternalBin( binName ) { + return Object.hasOwn( internalBinLoaders, binName ); +} diff --git a/src/lib/cli/runtime-mode.ts b/src/lib/cli/runtime-mode.ts new file mode 100644 index 000000000..c167b452e --- /dev/null +++ b/src/lib/cli/runtime-mode.ts @@ -0,0 +1,20 @@ +type SeaModule = { + isSea?: () => boolean; +}; + +export function isStandaloneExecutableRuntime(): boolean { + if ( process.env.VIP_CLI_SEA_MODE === '1' ) { + return true; + } + + try { + const sea = require( 'node:sea' ) as SeaModule; + return Boolean( sea?.isSea?.() ); + } catch { + return false; + } +} + +export function getRuntimeModeLabel(): string { + return isStandaloneExecutableRuntime() ? 'standalone-sea' : 'node-script'; +} diff --git a/src/lib/cli/sea-dispatch.js b/src/lib/cli/sea-dispatch.js new file mode 100644 index 000000000..651e24a10 --- /dev/null +++ b/src/lib/cli/sea-dispatch.js @@ -0,0 +1,96 @@ +import { isAlias } from './envAlias'; +import { internalBinNames } from './internal-bin-loader'; + +const internalBinSet = new Set( internalBinNames ); + +/** + * Resolve the best matching internal bin for a command argv. + * + * @param {string[]} argv process.argv style array + * @returns {{ bin: string, start: number, length: number }} + */ +export function resolveInternalBinFromArgv( argv ) { + const args = argv.slice( 2 ); + const dashDashIndex = args.indexOf( '--' ); + const commandBoundary = dashDashIndex > -1 ? dashDashIndex : args.length; + + let best = { + bin: 'vip', + start: 0, + length: 0, + }; + + for ( let start = 0; start < commandBoundary; start++ ) { + const firstToken = args[ start ]; + if ( ! firstToken || firstToken.startsWith( '-' ) || isAlias( firstToken ) ) { + continue; + } + + const commandParts = []; + + for ( let index = start; index < commandBoundary; index++ ) { + const token = args[ index ]; + if ( ! token || token.startsWith( '-' ) || isAlias( token ) ) { + break; + } + + commandParts.push( token ); + + const candidateBin = `vip-${ commandParts.join( '-' ) }`; + if ( internalBinSet.has( candidateBin ) ) { + const isLongerMatch = commandParts.length > best.length; + const isEarlierEqualMatch = + commandParts.length === best.length && commandParts.length > 0 && start < best.start; + + if ( isLongerMatch || isEarlierEqualMatch ) { + best = { + bin: candidateBin, + start, + length: commandParts.length, + }; + } + } + } + } + + return best; +} + +/** + * Rewrites argv so the resolved command segment is removed and the target bin + * can parse its native flags/args shape. + * + * @param {string[]} argv process.argv style array + * @param {{ start: number, length: number }} resolution command resolution + * @returns {string[]} rewritten argv + */ +export function rewriteArgvForInternalBin( argv, resolution ) { + const args = argv.slice( 2 ); + const start = resolution.start ?? 0; + const length = resolution.length ?? 0; + + if ( length <= 0 ) { + return argv.slice( 0 ); + } + + const rewrittenArgs = args.slice( 0, start ).concat( args.slice( start + length ) ); + return [ argv[ 0 ], argv[ 1 ], ...rewrittenArgs ]; +} + +/** + * @returns {boolean} + */ +export function isSeaRuntime() { + try { + const runtimeRequire = + typeof module !== 'undefined' && module?.require ? module.require.bind( module ) : null; + if ( ! runtimeRequire ) { + return false; + } + + const sea = runtimeRequire( 'node:sea' ); + return Boolean( sea?.isSea?.() ); + } catch { + return false; + } +} diff --git a/src/lib/cli/sea-runtime.js b/src/lib/cli/sea-runtime.js new file mode 100644 index 000000000..feba68324 --- /dev/null +++ b/src/lib/cli/sea-runtime.js @@ -0,0 +1,84 @@ +import { existsSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import pkg from '../../../package.json'; +import { xdgData } from '../xdg-data'; + +const RUNTIME_ARCHIVE_KEY = 'sea.node_modules.tgz'; +const RUNTIME_DIR_NAME = 'sea-runtime'; +const READY_FILE_NAME = '.ready'; +const ARCHIVE_FILE_NAME = 'node_modules.tgz'; + +async function getSeaModule() { + try { + return await import( 'node:sea' ); + } catch { + return null; + } +} + +function getRuntimeRootPath() { + return path.join( xdgData(), 'vip', RUNTIME_DIR_NAME, pkg.version ); +} + +function getRuntimeNodeModulesPath( runtimeRootPath ) { + return path.join( runtimeRootPath, 'node_modules' ); +} + +function getRuntimeReadyPath( runtimeRootPath ) { + return path.join( runtimeRootPath, READY_FILE_NAME ); +} + +function appendNodePath( nodeModulesPath ) { + const existing = process.env.NODE_PATH ? process.env.NODE_PATH.split( path.delimiter ) : []; + if ( ! existing.includes( nodeModulesPath ) ) { + process.env.NODE_PATH = [ nodeModulesPath, ...existing ].join( path.delimiter ); + } + + const Module = require( 'node:module' ); + Module.Module._initPaths(); + + const runtimeEntryPath = path.join( nodeModulesPath, '..', '__sea-entry__.js' ); + const runtimeRequire = Module.createRequire( runtimeEntryPath ); + module.filename = runtimeEntryPath; + module.paths = Module._nodeModulePaths( path.dirname( runtimeEntryPath ) ); + module.require = runtimeRequire; +} + +async function extractRuntimeDependencies( runtimeRootPath, archiveBuffer ) { + await rm( runtimeRootPath, { recursive: true, force: true } ); + await mkdir( runtimeRootPath, { recursive: true } ); + + const archivePath = path.join( runtimeRootPath, ARCHIVE_FILE_NAME ); + await writeFile( archivePath, archiveBuffer ); + + const tar = require( 'tar' ); + await tar.x( { + file: archivePath, + cwd: runtimeRootPath, + } ); + + await writeFile( getRuntimeReadyPath( runtimeRootPath ), pkg.version, 'utf8' ); +} + +export async function prepareSeaRuntimeFilesystem() { + const sea = await getSeaModule(); + if ( ! sea?.isSea?.() || ! sea.getAsset ) { + return; + } + + const runtimeRootPath = getRuntimeRootPath(); + const runtimeNodeModulesPath = getRuntimeNodeModulesPath( runtimeRootPath ); + const runtimeReadyPath = getRuntimeReadyPath( runtimeRootPath ); + + if ( ! existsSync( runtimeReadyPath ) || ! existsSync( runtimeNodeModulesPath ) ) { + const archiveAsset = sea.getAsset( RUNTIME_ARCHIVE_KEY ); + const archiveBuffer = Buffer.isBuffer( archiveAsset ) + ? archiveAsset + : Buffer.from( archiveAsset ); + await extractRuntimeDependencies( runtimeRootPath, archiveBuffer ); + } + + appendNodePath( runtimeNodeModulesPath ); +} diff --git a/src/lib/dev-environment/dev-environment-cli.ts b/src/lib/dev-environment/dev-environment-cli.ts index c9d3c97d6..36fb7aea0 100644 --- a/src/lib/dev-environment/dev-environment-cli.ts +++ b/src/lib/dev-environment/dev-environment-cli.ts @@ -2,8 +2,6 @@ import chalk from 'chalk'; import { spawn } from 'child_process'; import debugLib from 'debug'; import { prompt, Confirm, Select } from 'enquirer'; -import Lando from 'lando'; -import formatters from 'lando/lib/formatters'; import { existsSync, lstatSync, readdirSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'path'; @@ -19,6 +17,7 @@ import { generatePHPStormWorkspace, } from './dev-environment-core'; import { validateDockerInstalled } from './dev-environment-lando'; +import { loadLandoModule } from './lando-loader'; import { getCurrentUserInfo } from '../api/user'; import { Args } from '../cli/command'; import { @@ -33,6 +32,7 @@ import { import { trackEvent } from '../tracker'; import UserError from '../user-error'; +import type Lando from 'lando'; import type { AppInfo, ComponentConfig, @@ -51,6 +51,24 @@ export const CONFIGURATION_FOLDER = '.wpvip'; let isStdinTTY: boolean = Boolean( process.stdin.isTTY ); +type LandoFormatters = { + formatData: ( + data: Record< string, unknown >, + options: Record< string, unknown >, + style: Record< string, unknown > + ) => string; +}; + +let landoFormatters: LandoFormatters | null = null; + +const getLandoFormatters = (): LandoFormatters => { + if ( ! landoFormatters ) { + landoFormatters = loadLandoModule< LandoFormatters >( 'lando/lib/formatters' ); + } + + return landoFormatters; +}; + /** * Used internally for tests * @@ -184,7 +202,11 @@ export function getEnvironmentStartCommand( } export function printTable( data: Record< string, unknown > ) { - const formattedData = formatters.formatData( data, { format: 'table' }, { border: false } ); + const formattedData = getLandoFormatters().formatData( + data, + { format: 'table' }, + { border: false } + ); console.log( formattedData ); } diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 4d5f058bb..3cf489d88 100644 --- a/src/lib/dev-environment/dev-environment-core.ts +++ b/src/lib/dev-environment/dev-environment-core.ts @@ -3,7 +3,6 @@ import debugLib from 'debug'; import ejs from 'ejs'; import { prompt } from 'enquirer'; import { print } from 'graphql'; -import { dockerComposify } from 'lando/lib/utils'; import fetch from 'node-fetch'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; @@ -17,6 +16,7 @@ import { promptForWordPress, resolvePath, } from './dev-environment-cli'; +import { loadLandoModule } from './lando-loader'; import { landoDestroy, landoInfo, @@ -67,6 +67,7 @@ const landoFileTemplatePath = path.join( 'assets', 'dev-env.lando.template.yml.ejs' ); +const landoTemplateAssetKey = 'dev-env.lando.template.yml.ejs'; const nginxFileTemplatePath = path.join( __dirname, '..', @@ -75,6 +76,7 @@ const nginxFileTemplatePath = path.join( 'assets', 'dev-env.nginx.template.conf.ejs' ); +const nginxTemplateAssetKey = 'dev-env.nginx.template.conf.ejs'; const landoFileName = '.lando.yml'; const landoOverridesFileName = '.lando.local.yml'; const landoBackupFileName = '.lando.backup.yml'; @@ -103,7 +105,58 @@ interface WordPressTag { const STARTUP_READY_ATTEMPTS = 6; const STARTUP_READY_DELAY_MS = 2000; -const sleep = ( ms: number ): Promise< void > => new Promise( resolve => setTimeout( resolve, ms ) ); +let dockerComposifyFromLando: (( value: string ) => string) | null = null; + +const dockerComposify = ( value: string ): string => { + if ( ! dockerComposifyFromLando ) { + const landoUtils = loadLandoModule< { dockerComposify: ( input: string ) => string } >( + 'lando/lib/utils' + ); + dockerComposifyFromLando = landoUtils.dockerComposify; + } + + return dockerComposifyFromLando( value ); +}; + +const sleep = ( ms: number ): Promise< void > => + new Promise( resolve => setTimeout( resolve, ms ) ); + +type SeaModule = { + isSea?: () => boolean; + getAsset?: ( key: string, encoding?: BufferEncoding ) => string | ArrayBuffer; +}; + +let seaModulePromise: Promise< SeaModule | null > | null = null; + +const getSeaModule = async (): Promise< SeaModule | null > => { + if ( ! seaModulePromise ) { + seaModulePromise = ( async () => { + try { + return ( await import( 'node:sea' ) ) as SeaModule; + } catch { + return null; + } + } )(); + } + + return seaModulePromise; +}; + +const renderTemplateFile = async ( + filePath: string, + assetKey: string, + templateData: Record< string, unknown > +): Promise< string > => { + const sea = await getSeaModule(); + if ( sea?.isSea?.() && sea.getAsset ) { + const template = sea.getAsset( assetKey, 'utf8' ); + if ( typeof template === 'string' ) { + return ejs.render( template, templateData ); + } + } + + return ejs.renderFile( filePath, templateData ); +}; async function waitForEnvironmentToBeUp( lando: Lando, instancePath: string ): Promise< boolean > { for ( let attempt = 1; attempt <= STARTUP_READY_ATTEMPTS; attempt++ ) { @@ -579,8 +632,16 @@ async function prepareLandoEnv( domain: lando.config.domain, }; - const landoFile = await ejs.renderFile( landoFileTemplatePath, templateData ); - const nginxFile = await ejs.renderFile( nginxFileTemplatePath, templateData ); + const landoFile = await renderTemplateFile( + landoFileTemplatePath, + landoTemplateAssetKey, + templateData + ); + const nginxFile = await renderTemplateFile( + nginxFileTemplatePath, + nginxTemplateAssetKey, + templateData + ); const instanceDataFile = JSON.stringify( instanceData ); const landoFileTargetPath = path.join( instancePath, landoFileName ); diff --git a/src/lib/dev-environment/dev-environment-lando.ts b/src/lib/dev-environment/dev-environment-lando.ts index aae3dcdca..6b2a830d4 100644 --- a/src/lib/dev-environment/dev-environment-lando.ts +++ b/src/lib/dev-environment/dev-environment-lando.ts @@ -1,11 +1,6 @@ import chalk from 'chalk'; import debugLib from 'debug'; import Dockerode from 'dockerode'; -import App, { type ScanResult } from 'lando/lib/app'; -import { buildConfig } from 'lando/lib/bootstrap'; -import Lando, { type LandoConfig } from 'lando/lib/lando'; -import landoUtils, { type AppInfo } from 'lando/plugins/lando-core/lib/utils'; -import landoBuildTask from 'lando/plugins/lando-tooling/lib/build'; import { execFile } from 'node:child_process'; import { lookup } from 'node:dns/promises'; import { mkdir, rename, unlink, stat, writeFile } from 'node:fs/promises'; @@ -21,13 +16,20 @@ import { updateEnvironment, writeEnvironmentData, } from './dev-environment-core'; +import { loadLandoModule, resolveLandoModule } from './lando-loader'; import { getDockerSocket, getEngineConfig } from './docker-utils'; import { DEV_ENVIRONMENT_NOT_FOUND } from '../constants/dev-environment'; import env from '../env'; +import { getRuntimeModeLabel } from '../cli/runtime-mode'; import UserError from '../user-error'; import { xdgData } from '../xdg-data'; import type { NetworkInspectInfo } from 'dockerode'; +import type App from 'lando/lib/app'; +import type { ScanResult } from 'lando/lib/app'; +import type { LandoConfig } from 'lando/lib/lando'; +import type Lando from 'lando/lib/lando'; +import type { AppInfo } from 'lando/plugins/lando-core/lib/utils'; import type Landerode from 'lando/lib/docker'; import type { StdioOptions } from 'node:child_process'; @@ -55,6 +57,73 @@ interface LandoConfigWithLogging extends Omit< LandoConfig, 'composeBin' | 'dock } const execFileAsync = promisify( execFile ); + +type LandoConstructor = new ( config: LandoConfig ) => Lando; +type LandoBuildTask = ( + tool: Record< string, unknown >, + lando: Lando +) => { + run: ( argv: Record< string, unknown > ) => Promise< void > | void; +}; + +const unwrapLandoModuleDefault = < T >( loaded: unknown ): T => { + if ( loaded && typeof loaded === 'object' && 'default' in loaded ) { + return ( loaded as { default: T } ).default; + } + + return loaded as T; +}; + +let landoConstructor: LandoConstructor | null = null; +let landoBuildTaskFn: LandoBuildTask | null = null; +let landoUtilsModule: { startTable: ( app: App ) => AppInfo } | null = null; +let buildConfigFn: (( config: Record< string, unknown > ) => LandoConfig) | null = null; + +const getLandoConstructor = (): LandoConstructor => { + if ( ! landoConstructor ) { + landoConstructor = unwrapLandoModuleDefault< LandoConstructor >( + loadLandoModule( 'lando/lib/lando' ) + ); + } + + return landoConstructor; +}; + +const getLandoBuildTask = (): LandoBuildTask => { + if ( ! landoBuildTaskFn ) { + landoBuildTaskFn = unwrapLandoModuleDefault< LandoBuildTask >( + loadLandoModule( 'lando/plugins/lando-tooling/lib/build' ) + ); + } + + return landoBuildTaskFn; +}; + +const getLandoUtils = (): { startTable: ( app: App ) => AppInfo } => { + if ( ! landoUtilsModule ) { + landoUtilsModule = unwrapLandoModuleDefault< { startTable: ( app: App ) => AppInfo } >( + loadLandoModule( 'lando/plugins/lando-core/lib/utils' ) + ); + } + + return landoUtilsModule; +}; + +const getLandoBuildConfig = (): (( config: Record< string, unknown > ) => LandoConfig) => { + if ( ! buildConfigFn ) { + const loaded = loadLandoModule< { + buildConfig?: ( config: Record< string, unknown > ) => LandoConfig; + default?: { buildConfig?: ( config: Record< string, unknown > ) => LandoConfig }; + } >( 'lando/lib/bootstrap' ); + + buildConfigFn = loaded.buildConfig ?? loaded.default?.buildConfig ?? null; + if ( ! buildConfigFn ) { + throw new Error( 'Unable to load Lando bootstrap buildConfig.' ); + } + } + + return buildConfigFn; +}; const bannerLabelWidth = 18; let logPathRegistered = false; let resolvedLogPath: string | null = null; @@ -195,6 +264,7 @@ const writeLogBanner = async ( config: LandoConfigWithLogging ): Promise< void > formatBannerLine( 'OS', `${ env.os.name } ${ env.os.version } ${ env.os.arch }` ), formatBannerLine( 'NODE', env.node.version ), formatBannerLine( 'VIP-CLI', env.app.version ), + formatBannerLine( 'RUNTIME', getRuntimeModeLabel() ), formatBannerLine( 'DOCKER ENGINE', dockerVersions.engine ), formatBannerLine( 'DOCKER COMPOSE', dockerVersions.compose ), formatBannerLine( 'COMPOSE PLUGIN', dockerVersions.composePlugin ), @@ -215,7 +285,7 @@ const writeLogBanner = async ( config: LandoConfigWithLogging ): Promise< void > */ async function getLandoConfig( options: LandoBootstrapOptions = {} ): Promise< LandoConfig > { // The path will be smth like `yarn/global/node_modules/lando/lib/lando.js`; we need the path up to `lando` (inclusive) - const landoPath = dirname( dirname( require.resolve( 'lando' ) ) ); + const landoPath = dirname( dirname( resolveLandoModule( 'lando' ) ) ); debug( `Getting Lando config, using paths '${ landoPath }' for plugins` ); @@ -263,7 +333,7 @@ async function getLandoConfig( options: LandoBootstrapOptions = {} ): Promise< L }, }; - return buildConfig( config ); + return getLandoBuildConfig()( config ); } const appMap = new Map< string, App >(); @@ -383,7 +453,8 @@ export async function bootstrapLando( options: LandoBootstrapOptions = {} ): Pro registerLogPathOutput( config as LandoConfigWithLogging ); await writeLogBanner( config as LandoConfigWithLogging ); - const lando = new Lando( config ); + const LandoClass = getLandoConstructor(); + const lando = new LandoClass( config ); debugLib.log = ( message: string, ...args: unknown[] ) => { lando.log.debug( message, ...args ); }; @@ -565,7 +636,7 @@ export async function landoInfo( try { const app = await getLandoApplication( lando, instancePath ); - const info = landoUtils.startTable( app ); + const info = getLandoUtils().startTable( app ); const reachableServices = app.info.filter( service => service.urls.length ); reachableServices.forEach( service => ( info[ `${ service.service } urls` ] = service.urls ) ); @@ -859,7 +930,7 @@ export async function landoExec( tool.stdio = options.stdio; } - const task = landoBuildTask( tool, lando ); + const task = getLandoBuildTask()( tool, lando ); const argv = { // eslint-disable-next-line id-length diff --git a/src/lib/dev-environment/lando-loader.ts b/src/lib/dev-environment/lando-loader.ts new file mode 100644 index 000000000..e702a18cb --- /dev/null +++ b/src/lib/dev-environment/lando-loader.ts @@ -0,0 +1,54 @@ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import pkg from '../../../package.json'; +import { xdgData } from '../xdg-data'; + +const SEA_RUNTIME_DIR_NAME = 'sea-runtime'; + +let cachedRequire: NodeJS.Require | null = null; +let didResolveRequire = false; + +function isSeaRuntime(): boolean { + try { + const sea = require( 'node:sea' ) as { + isSea?: () => boolean; + }; + return Boolean( sea?.isSea?.() ); + } catch { + return false; + } +} + +function getSeaRuntimeNodeModulesPath(): string { + return path.join( xdgData(), 'vip', SEA_RUNTIME_DIR_NAME, pkg.version, 'node_modules' ); +} + +function getRuntimeRequire(): NodeJS.Require { + if ( didResolveRequire && cachedRequire ) { + return cachedRequire; + } + + didResolveRequire = true; + + if ( isSeaRuntime() ) { + const runtimeNodeModulesPath = getSeaRuntimeNodeModulesPath(); + if ( existsSync( runtimeNodeModulesPath ) ) { + const runtimeEntryPath = path.join( runtimeNodeModulesPath, '..', '__sea-entry__.js' ); + cachedRequire = createRequire( runtimeEntryPath ); + return cachedRequire; + } + } + + cachedRequire = require; + return cachedRequire; +} + +export function loadLandoModule< T = unknown >( request: string ): T { + return getRuntimeRequire()( request ) as T; +} + +export function resolveLandoModule( request: string ): string { + return getRuntimeRequire().resolve( request ); +} From e93b8b2f2cf35e364b6acb1306c5efcc4b788642 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 16:39:43 -0600 Subject: [PATCH 02/10] First pass at SEA GH workflow --- .github/workflows/sea-build-sign.yml | 169 +++++++++++++++++++++++++++ docs/SEA-BUILD-SIGNING.md | 10 ++ 2 files changed, 179 insertions(+) create mode 100644 .github/workflows/sea-build-sign.yml diff --git a/.github/workflows/sea-build-sign.yml b/.github/workflows/sea-build-sign.yml new file mode 100644 index 000000000..82a4c11f0 --- /dev/null +++ b/.github/workflows/sea-build-sign.yml @@ -0,0 +1,169 @@ +name: Build SEA Artifacts + +on: + workflow_dispatch: + inputs: + sign_artifacts: + description: 'Sign artifacts when signing secrets are configured.' + required: false + default: false + type: boolean + +permissions: + contents: read + +env: + NODE_OPTIONS: --unhandled-rejections=warn + DO_NOT_TRACK: '1' + +jobs: + build-sea-native: + name: SEA Native (${{ matrix.target.name }}) + runs-on: ${{ matrix.target.runs_on }} + strategy: + fail-fast: false + matrix: + target: + - name: macOS + runs_on: macos-latest + binary_path: dist/sea/vip + artifact_name: vip-sea-macos + - name: Linux + runs_on: ubuntu-latest + binary_path: dist/sea/vip + artifact_name: vip-sea-linux + - name: Windows + runs_on: windows-latest + binary_path: dist/sea/vip.exe + artifact_name: vip-sea-windows + + steps: + - name: Check out the source code + uses: actions/checkout@v6 + + - name: Set git to use LF + if: runner.os == 'Windows' + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - name: Set up Node.js 22 + uses: actions/setup-node@v6 + with: + node-version: '22.x' + cache: npm + cache-dependency-path: npm-shrinkwrap.json + + - name: Install dependencies + run: npm ci + + - name: Build dist + run: npm run build + + - name: Build SEA artifact + run: npm run build:sea + + - name: Smoke test executable (Unix) + if: runner.os != 'Windows' + run: | + ${{ matrix.target.binary_path }} --version + ${{ matrix.target.binary_path }} whoami --help + + - name: Smoke test executable (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + .\${{ matrix.target.binary_path }} --version + .\${{ matrix.target.binary_path }} whoami --help + + - name: Import macOS signing certificate + if: runner.os == 'macOS' && inputs.sign_artifacts && secrets.MACOS_CERTIFICATE_P12_BASE64 != '' && secrets.MACOS_CERTIFICATE_PASSWORD != '' && secrets.MACOS_SIGNING_IDENTITY != '' + env: + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + CERT_PATH="$RUNNER_TEMP/macos-cert.p12" + + echo "$MACOS_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" + security create-keychain -p "" "$KEYCHAIN_PATH" + security unlock-keychain -p "" "$KEYCHAIN_PATH" + security list-keychains -s "$KEYCHAIN_PATH" + security default-keychain -s "$KEYCHAIN_PATH" + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN_PATH" + + codesign --remove-signature ${{ matrix.target.binary_path }} + codesign --sign "$MACOS_SIGNING_IDENTITY" --force --options runtime ${{ matrix.target.binary_path }} + codesign --verify --strict --verbose=2 ${{ matrix.target.binary_path }} + + - name: Sign Windows executable + if: runner.os == 'Windows' && inputs.sign_artifacts && secrets.WINDOWS_CERTIFICATE_PFX_BASE64 != '' && secrets.WINDOWS_CERTIFICATE_PASSWORD != '' + shell: pwsh + env: + WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_PFX_BASE64 }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }} + run: | + $certPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx' + [System.IO.File]::WriteAllBytes($certPath, [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_PFX_BASE64)) + $timestampUrl = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' } + + signtool sign /fd SHA256 /td SHA256 /tr $timestampUrl /f $certPath /p $env:WINDOWS_CERTIFICATE_PASSWORD .\${{ matrix.target.binary_path }} + signtool verify /pa /v .\${{ matrix.target.binary_path }} + + - name: Generate checksum (Unix) + if: runner.os != 'Windows' + run: | + shasum -a 256 ${{ matrix.target.binary_path }} > ${{ matrix.target.binary_path }}.sha256 + + - name: Generate checksum (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $hash = (Get-FileHash -Algorithm SHA256 .\${{ matrix.target.binary_path }}).Hash.ToLower() + "${hash} *${{ matrix.target.binary_path }}" | Set-Content .\${{ matrix.target.binary_path }}.sha256 -NoNewline + + - name: Upload SEA artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target.artifact_name }} + path: | + ${{ matrix.target.binary_path }} + ${{ matrix.target.binary_path }}.sha256 + if-no-files-found: error + + build-sea-windows-wsl: + name: SEA (Windows WSL) + runs-on: windows-latest + + steps: + - name: Check out the source code + uses: actions/checkout@v6 + + - name: Set git to use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - name: Build SEA inside WSL + shell: pwsh + run: | + $workspaceWsl = (wsl.exe wslpath -a "$env:GITHUB_WORKSPACE").Trim() + if (-not $workspaceWsl) { + throw 'Failed to resolve WSL workspace path.' + } + + wsl.exe bash -lc "set -euo pipefail; export NVM_DIR=\$HOME/.nvm; if [ ! -s \"\$NVM_DIR/nvm.sh\" ]; then curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash; fi" + wsl.exe bash -lc "set -euo pipefail; export NVM_DIR=\$HOME/.nvm; . \"\$NVM_DIR/nvm.sh\"; nvm install 22; nvm use 22; cd \"$workspaceWsl\"; npm ci; npm run build; npm run build:sea; ./dist/sea/vip --version; ./dist/sea/vip whoami --help" + wsl.exe bash -lc "set -euo pipefail; cd \"$workspaceWsl\"; sha256sum dist/sea/vip > dist/sea/vip.sha256" + + - name: Upload WSL SEA artifact + uses: actions/upload-artifact@v4 + with: + name: vip-sea-windows-wsl + path: | + dist/sea/vip + dist/sea/vip.sha256 + if-no-files-found: error diff --git a/docs/SEA-BUILD-SIGNING.md b/docs/SEA-BUILD-SIGNING.md index 9b0509f33..a15ef4f74 100644 --- a/docs/SEA-BUILD-SIGNING.md +++ b/docs/SEA-BUILD-SIGNING.md @@ -135,3 +135,13 @@ powershell.exe -NoProfile -Command "Set-Location '$WIN_REPO_PATH'; signtool sign - Apply platform-appropriate signature method. - Verify signature/checksum before publishing. - Record signing method and timestamp authority in release notes. + +## GitHub Actions Automation +- Workflow: `.github/workflows/sea-build-sign.yml` +- Trigger: manual `workflow_dispatch` +- Jobs: native macOS/Linux/Windows SEA builds plus a Windows WSL SEA build +- Optional signing: set `sign_artifacts=true` and provide signing secrets/vars: + - macOS: `MACOS_CERTIFICATE_P12_BASE64`, `MACOS_CERTIFICATE_PASSWORD`, `MACOS_SIGNING_IDENTITY` + - Windows: `WINDOWS_CERTIFICATE_PFX_BASE64`, `WINDOWS_CERTIFICATE_PASSWORD` + - optional variable: `WINDOWS_TIMESTAMP_URL` +- Output: uploaded SEA binary + SHA256 artifact per job From f9224a35de6a02c6d6ad8979de6c46e7e465331c Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 12 Feb 2026 16:51:22 -0600 Subject: [PATCH 03/10] Pass 2 at GHA workflow --- .github/workflows/sea-build-sign.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sea-build-sign.yml b/.github/workflows/sea-build-sign.yml index 82a4c11f0..cda66b0c5 100644 --- a/.github/workflows/sea-build-sign.yml +++ b/.github/workflows/sea-build-sign.yml @@ -20,6 +20,14 @@ jobs: build-sea-native: name: SEA Native (${{ matrix.target.name }}) runs-on: ${{ matrix.target.runs_on }} + env: + SIGN_ARTIFACTS: ${{ inputs.sign_artifacts }} + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_PFX_BASE64 }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }} strategy: fail-fast: false matrix: @@ -77,11 +85,7 @@ jobs: .\${{ matrix.target.binary_path }} whoami --help - name: Import macOS signing certificate - if: runner.os == 'macOS' && inputs.sign_artifacts && secrets.MACOS_CERTIFICATE_P12_BASE64 != '' && secrets.MACOS_CERTIFICATE_PASSWORD != '' && secrets.MACOS_SIGNING_IDENTITY != '' - env: - MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + if: runner.os == 'macOS' && env.SIGN_ARTIFACTS == 'true' && env.MACOS_CERTIFICATE_P12_BASE64 != '' && env.MACOS_CERTIFICATE_PASSWORD != '' && env.MACOS_SIGNING_IDENTITY != '' run: | KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" CERT_PATH="$RUNNER_TEMP/macos-cert.p12" @@ -99,12 +103,8 @@ jobs: codesign --verify --strict --verbose=2 ${{ matrix.target.binary_path }} - name: Sign Windows executable - if: runner.os == 'Windows' && inputs.sign_artifacts && secrets.WINDOWS_CERTIFICATE_PFX_BASE64 != '' && secrets.WINDOWS_CERTIFICATE_PASSWORD != '' + if: runner.os == 'Windows' && env.SIGN_ARTIFACTS == 'true' && env.WINDOWS_CERTIFICATE_PFX_BASE64 != '' && env.WINDOWS_CERTIFICATE_PASSWORD != '' shell: pwsh - env: - WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_PFX_BASE64 }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }} run: | $certPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx' [System.IO.File]::WriteAllBytes($certPath, [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_PFX_BASE64)) From 5a27d404b21b64bad1f9b4ce0810eee979b238ea Mon Sep 17 00:00:00 2001 From: Rinat K Date: Thu, 12 Feb 2026 20:29:59 -0600 Subject: [PATCH 04/10] Update helpers/build-sea.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- helpers/build-sea.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/build-sea.js b/helpers/build-sea.js index 54159ab20..74e11a927 100644 --- a/helpers/build-sea.js +++ b/helpers/build-sea.js @@ -132,7 +132,7 @@ function injectBlob() { args.push( '--macho-segment-name', 'NODE_SEA' ); } - if ( process.platform === 'windows' ) { + if ( process.platform === 'win32' ) { args.push( '--overwrite' ); } From 453db7dffb432995bf3c10c6d4dbe96e85d37432 Mon Sep 17 00:00:00 2001 From: Rinat K Date: Thu, 12 Feb 2026 20:30:24 -0600 Subject: [PATCH 05/10] Update AGENTS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6ba0f2218..181e65e77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting - On 401, the client prints a custom message and exits; ensure authenticated tests stub the network or set `silenceAuthErrors`/`exitOnError=false` when constructing the client. ## Dev-Env Subsystem (High Blast Radius) -- Implemented under `src/lib/dev-environment/**`; shells out to Lando and Docker, renders templates from `assets/dev-env.*.ejs`, and writes to per-environment folders inside `xdgData()/vip-cli` (overridden by `XDG_DATA_HOME`). Running these commands mutates local docker networks and may fetch WP/PHP version metadata from GitHub constants. +- Implemented under `src/lib/dev-environment/**`; shells out to Lando and Docker, renders templates from `assets/dev-env.*.ejs`, and writes to per-environment folders inside `xdgData()/vip` (overridden by `XDG_DATA_HOME`). Running these commands mutates local docker networks and may fetch WP/PHP version metadata from GitHub constants. - Proxy helpers live in `src/lib/http/proxy-*`; dev-env code constructs agents automatically using `VIP_PROXY`/`SOCKS_PROXY`/`HTTP_PROXY`/`VIP_USE_SYSTEM_PROXY`. Unexpected proxies can break downloads—clear those env vars when debugging. - Avoid invoking dev-env logic in unit tests unless you mock `lando`, filesystem, and network; the E2E suite covers the real paths. - Runtime resilience safeguards: From a3ce33e05f8c4eaabf0015a0264ca86209506ab8 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 18 Mar 2026 18:32:46 -0500 Subject: [PATCH 06/10] Fix merge fail --- src/lib/dev-environment/dev-environment-core.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 1c83b955e..4c3d48ab6 100644 --- a/src/lib/dev-environment/dev-environment-core.ts +++ b/src/lib/dev-environment/dev-environment-core.ts @@ -156,9 +156,7 @@ const renderTemplateFile = async ( } return ejs.renderFile( filePath, templateData ); -}; -const sleep = ( ms: number ): Promise< void > => - new Promise( resolve => setTimeout( resolve, ms ) ); +} async function waitForEnvironmentToBeUp( lando: Lando, instancePath: string ): Promise< boolean > { for ( let attempt = 1; attempt <= STARTUP_READY_ATTEMPTS; attempt++ ) { From a75fe554b77106f604b7453f54220c660ab114a8 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Wed, 18 Mar 2026 18:34:38 -0500 Subject: [PATCH 07/10] Lint --- src/commands/dev-env-sync-sql.ts | 3 ++- src/lib/cli/exit.ts | 2 +- src/lib/dev-environment/dev-environment-cli.ts | 2 +- src/lib/dev-environment/dev-environment-core.ts | 6 +++--- src/lib/dev-environment/dev-environment-lando.ts | 10 +++++----- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/commands/dev-env-sync-sql.ts b/src/commands/dev-env-sync-sql.ts index cfeea912f..aa822e705 100644 --- a/src/commands/dev-env-sync-sql.ts +++ b/src/commands/dev-env-sync-sql.ts @@ -6,7 +6,6 @@ import chalk from 'chalk'; import debugLib from 'debug'; import fs from 'fs'; import gql from 'graphql-tag'; -import type Lando from 'lando'; import { pipeline } from 'node:stream/promises'; import { DevEnvImportSQLCommand, DevEnvImportSQLOptions } from './dev-env-import-sql'; @@ -23,6 +22,8 @@ import { LiveBackupCopyCLIOptions } from '../lib/live-backup-copy'; import { makeTempDir } from '../lib/utils'; import { getReadInterface } from '../lib/validations/line-by-line'; +import type Lando from 'lando'; + const debug = debugLib( '@automattic/vip:bin:dev-environment' ); /** diff --git a/src/lib/cli/exit.ts b/src/lib/cli/exit.ts index a9f86ec8e..cec6379a3 100644 --- a/src/lib/cli/exit.ts +++ b/src/lib/cli/exit.ts @@ -1,8 +1,8 @@ import chalk from 'chalk'; import debug from 'debug'; -import env from '../../lib/env'; import { getRuntimeModeLabel } from './runtime-mode'; +import env from '../../lib/env'; export function withError( message: Error | string ): never { const msg = message instanceof Error ? message.message : message; diff --git a/src/lib/dev-environment/dev-environment-cli.ts b/src/lib/dev-environment/dev-environment-cli.ts index 4f7d092a1..af46f1287 100644 --- a/src/lib/dev-environment/dev-environment-cli.ts +++ b/src/lib/dev-environment/dev-environment-cli.ts @@ -32,7 +32,6 @@ import { import { trackEvent } from '../tracker'; import UserError from '../user-error'; -import type Lando from 'lando'; import type { AppInfo, ComponentConfig, @@ -43,6 +42,7 @@ import type { ConfigurationFileOptions, MultisiteKind, } from './types'; +import type Lando from 'lando'; const debug = debugLib( '@automattic/vip:bin:dev-environment' ); diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 4c3d48ab6..723f82ef0 100644 --- a/src/lib/dev-environment/dev-environment-core.ts +++ b/src/lib/dev-environment/dev-environment-core.ts @@ -16,7 +16,6 @@ import { promptForWordPress, resolvePath, } from './dev-environment-cli'; -import { loadLandoModule } from './lando-loader'; import { landoDestroy, landoInfo, @@ -31,6 +30,7 @@ import { isEnvUp, removeProxyCache, } from './dev-environment-lando'; +import { loadLandoModule } from './lando-loader'; import { AppEnvironment } from '../../graphqlTypes'; import app from '../api/app'; import { appQueryFragments as softwareQueryFragment } from '../config/software'; @@ -105,7 +105,7 @@ interface WordPressTag { const STARTUP_READY_ATTEMPTS = 6; const STARTUP_READY_DELAY_MS = 2000; -let dockerComposifyFromLando: (( value: string ) => string) | null = null; +let dockerComposifyFromLando: ( ( value: string ) => string ) | null = null; const dockerComposify = ( value: string ): string => { if ( ! dockerComposifyFromLando ) { @@ -156,7 +156,7 @@ const renderTemplateFile = async ( } return ejs.renderFile( filePath, templateData ); -} +}; async function waitForEnvironmentToBeUp( lando: Lando, instancePath: string ): Promise< boolean > { for ( let attempt = 1; attempt <= STARTUP_READY_ATTEMPTS; attempt++ ) { diff --git a/src/lib/dev-environment/dev-environment-lando.ts b/src/lib/dev-environment/dev-environment-lando.ts index 057b80ec9..aec30bc57 100644 --- a/src/lib/dev-environment/dev-environment-lando.ts +++ b/src/lib/dev-environment/dev-environment-lando.ts @@ -16,21 +16,21 @@ import { updateEnvironment, writeEnvironmentData, } from './dev-environment-core'; -import { loadLandoModule, resolveLandoModule } from './lando-loader'; import { getDockerSocket, getEngineConfig } from './docker-utils'; +import { loadLandoModule, resolveLandoModule } from './lando-loader'; +import { getRuntimeModeLabel } from '../cli/runtime-mode'; import { DEV_ENVIRONMENT_NOT_FOUND } from '../constants/dev-environment'; import env from '../env'; -import { getRuntimeModeLabel } from '../cli/runtime-mode'; import UserError from '../user-error'; import { xdgData } from '../xdg-data'; import type { NetworkInspectInfo } from 'dockerode'; import type App from 'lando/lib/app'; import type { ScanResult } from 'lando/lib/app'; +import type Landerode from 'lando/lib/docker'; import type { LandoConfig } from 'lando/lib/lando'; import type Lando from 'lando/lib/lando'; import type { AppInfo } from 'lando/plugins/lando-core/lib/utils'; -import type Landerode from 'lando/lib/docker'; import type { StdioOptions } from 'node:child_process'; export interface LandoExecOptions { @@ -78,7 +78,7 @@ const unwrapLandoModuleDefault = < T >( loaded: unknown ): T => { let landoConstructor: LandoConstructor | null = null; let landoBuildTaskFn: LandoBuildTask | null = null; let landoUtilsModule: { startTable: ( app: App ) => AppInfo } | null = null; -let buildConfigFn: (( config: Record< string, unknown > ) => LandoConfig) | null = null; +let buildConfigFn: ( ( config: Record< string, unknown > ) => LandoConfig ) | null = null; const getLandoConstructor = (): LandoConstructor => { if ( ! landoConstructor ) { @@ -110,7 +110,7 @@ const getLandoUtils = (): { startTable: ( app: App ) => AppInfo } => { return landoUtilsModule; }; -const getLandoBuildConfig = (): (( config: Record< string, unknown > ) => LandoConfig) => { +const getLandoBuildConfig = (): ( ( config: Record< string, unknown > ) => LandoConfig ) => { if ( ! buildConfigFn ) { const loaded = loadLandoModule< { buildConfig?: ( config: Record< string, unknown > ) => LandoConfig; From 8aea4b8f5fa1ce0ce74979d53c984faf80d620f4 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Mon, 23 Mar 2026 12:38:12 -0500 Subject: [PATCH 08/10] Handle ENOENT in loadConfigFile --- src/lib/cli/config.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/lib/cli/config.ts b/src/lib/cli/config.ts index e780f716a..ce6cb5d91 100644 --- a/src/lib/cli/config.ts +++ b/src/lib/cli/config.ts @@ -13,12 +13,13 @@ interface Config { const debug = debugLib( '@automattic/vip:lib:cli:config' ); -export function loadConfigFile(): Config { +export function loadConfigFile(): Config | null { const paths = [ // Get `local` config first; this will only exist in dev as it's npmignore-d. path.join( __dirname, '../../../config/config.local.json' ), path.join( __dirname, '../../../config/config.publish.json' ), ]; + let hasNonEnoentError = false; for ( const filePath of paths ) { try { @@ -26,14 +27,28 @@ export function loadConfigFile(): Config { debug( `Found config file at ${ filePath }` ); return JSON.parse( data ) as Config; } catch ( err ) { - if ( ! ( err instanceof Error ) || ! ( 'code' in err ) || err.code !== 'ENOENT' ) { + const isEnoent = err instanceof Error && 'code' in err && err.code === 'ENOENT'; + if ( ! isEnoent ) { + hasNonEnoentError = true; debug( `Error reading config file at ${ filePath }:`, err ); } } } - return defaultPublishConfig as Config; + // SEA builds can miss on-disk config files, so use the bundled publish config only for ENOENT. + if ( ! hasNonEnoentError ) { + return defaultPublishConfig as Config; + } + + return null; } const configFromFile = loadConfigFile(); -export default configFromFile; +if ( null === configFromFile ) { + console.error( 'FATAL ERROR: Could not find a valid configuration file' ); + process.exit( 1 ); +} + +// Without this, TypeScript will export `configFromFile` as `Config | null`. +const exportedConfig: Config = configFromFile; +export default exportedConfig; From 659ff5853e4b2277c780195c4e37b7febd0af29f Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Mon, 23 Mar 2026 13:06:10 -0500 Subject: [PATCH 09/10] Lint --- src/bin/vip-dev-env-exec.js | 18 ++++++++------- src/lib/cli/runtime-mode.ts | 6 ++++- .../dev-environment/dev-environment-core.ts | 23 ++++++++++++------- src/lib/dev-environment/lando-loader.ts | 5 ++-- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/bin/vip-dev-env-exec.js b/src/bin/vip-dev-env-exec.js index 3f3e1399d..3addfa5e5 100755 --- a/src/bin/vip-dev-env-exec.js +++ b/src/bin/vip-dev-env-exec.js @@ -24,18 +24,20 @@ const sleep = ms => new Promise( resolve => setTimeout( resolve, ms ) ); async function waitForEnvironmentReadiness( lando, slug ) { const instancePath = getEnvironmentPath( slug ); + return pollEnvironmentReadiness( lando, instancePath, 1 ); +} - for ( let attempt = 1; attempt <= ENV_UP_CHECK_ATTEMPTS; attempt++ ) { - if ( await isEnvUp( lando, instancePath ) ) { - return true; - } +async function pollEnvironmentReadiness( lando, instancePath, attempt ) { + if ( await isEnvUp( lando, instancePath ) ) { + return true; + } - if ( attempt < ENV_UP_CHECK_ATTEMPTS ) { - await sleep( ENV_UP_CHECK_DELAY_MS ); - } + if ( attempt >= ENV_UP_CHECK_ATTEMPTS ) { + return false; } - return false; + await sleep( ENV_UP_CHECK_DELAY_MS ); + return pollEnvironmentReadiness( lando, instancePath, attempt + 1 ); } const examples = [ diff --git a/src/lib/cli/runtime-mode.ts b/src/lib/cli/runtime-mode.ts index c167b452e..d59e37c24 100644 --- a/src/lib/cli/runtime-mode.ts +++ b/src/lib/cli/runtime-mode.ts @@ -1,14 +1,18 @@ +import { createRequire } from 'node:module'; + type SeaModule = { isSea?: () => boolean; }; +const runtimeRequire = createRequire( __filename ); + export function isStandaloneExecutableRuntime(): boolean { if ( process.env.VIP_CLI_SEA_MODE === '1' ) { return true; } try { - const sea = require( 'node:sea' ) as SeaModule; + const sea = runtimeRequire( 'node:sea' ) as SeaModule; return Boolean( sea?.isSea?.() ); } catch { return false; diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 723f82ef0..0f64ef80f 100644 --- a/src/lib/dev-environment/dev-environment-core.ts +++ b/src/lib/dev-environment/dev-environment-core.ts @@ -159,17 +159,24 @@ const renderTemplateFile = async ( }; async function waitForEnvironmentToBeUp( lando: Lando, instancePath: string ): Promise< boolean > { - for ( let attempt = 1; attempt <= STARTUP_READY_ATTEMPTS; attempt++ ) { - if ( await isEnvUp( lando, instancePath ) ) { - return true; - } + return pollEnvironmentUpStatus( lando, instancePath, 1 ); +} - if ( attempt < STARTUP_READY_ATTEMPTS ) { - await sleep( STARTUP_READY_DELAY_MS ); - } +async function pollEnvironmentUpStatus( + lando: Lando, + instancePath: string, + attempt: number +): Promise< boolean > { + if ( await isEnvUp( lando, instancePath ) ) { + return true; } - return false; + if ( attempt >= STARTUP_READY_ATTEMPTS ) { + return false; + } + + await sleep( STARTUP_READY_DELAY_MS ); + return pollEnvironmentUpStatus( lando, instancePath, attempt + 1 ); } export interface PostStartOptions { diff --git a/src/lib/dev-environment/lando-loader.ts b/src/lib/dev-environment/lando-loader.ts index e702a18cb..c0fabaa18 100644 --- a/src/lib/dev-environment/lando-loader.ts +++ b/src/lib/dev-environment/lando-loader.ts @@ -9,10 +9,11 @@ const SEA_RUNTIME_DIR_NAME = 'sea-runtime'; let cachedRequire: NodeJS.Require | null = null; let didResolveRequire = false; +const baseRequire = createRequire( __filename ); function isSeaRuntime(): boolean { try { - const sea = require( 'node:sea' ) as { + const sea = baseRequire( 'node:sea' ) as { isSea?: () => boolean; }; return Boolean( sea?.isSea?.() ); @@ -41,7 +42,7 @@ function getRuntimeRequire(): NodeJS.Require { } } - cachedRequire = require; + cachedRequire = baseRequire; return cachedRequire; } From ced464e78ee57bdec190094e911ea64ec6217d1c Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Mon, 23 Mar 2026 13:42:35 -0500 Subject: [PATCH 10/10] prettier fix for MD --- AGENTS.md | 1 + docs/SEA-BUILD-SIGNING.md | 24 ++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9b3b03731..297250012 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting - `helpers/prepublishOnly.js` enforces branch `trunk` for `npm publish --tag latest` and optionally reruns `npm test`. Release flows expect a clean node version that satisfies `engines.node`. ## Standalone SEA Packaging + - Canonical runbook for standalone executable build/signing is in `docs/SEA-BUILD-SIGNING.md`. Use it for macOS, Linux, Windows native, and WSL-mediated Windows builds. - SEA builds are Node 22 only (enforced in `helpers/build-sea.js`); always verify `node -v` before `npm run build:sea`. - The executable is self-contained for Node runtime + JS deps, but `dev-env` commands still require host Docker/Compose availability. diff --git a/docs/SEA-BUILD-SIGNING.md b/docs/SEA-BUILD-SIGNING.md index a15ef4f74..9f0298e45 100644 --- a/docs/SEA-BUILD-SIGNING.md +++ b/docs/SEA-BUILD-SIGNING.md @@ -5,6 +5,7 @@ Purpose: build and sign the standalone VIP CLI executable (`dist/sea/vip` or `di This repo uses `helpers/build-sea.js` and the `npm run build:sea` script. SEA build is pinned to Node 22. ## Shared Prerequisites + - Use Node 22.x exactly for SEA builds. - Install dependencies before building: `npm ci`. - Build from repo root. @@ -17,6 +18,7 @@ This repo uses `helpers/build-sea.js` and the `npm run build:sea` script. SEA bu - Windows: `dist/sea/vip.exe` ## Shared Build Steps + ```bash npm ci npm run build @@ -24,6 +26,7 @@ npm run build:sea ``` Quick smoke checks after every build: + ```bash dist/sea/vip --version dist/sea/vip whoami --help @@ -31,7 +34,9 @@ dist/sea/vip dev-env info --help ``` ## macOS (native) + Node/tool setup: + ```bash export NVM_DIR="$HOME/.nvm" . "$NVM_DIR/nvm.sh" @@ -40,6 +45,7 @@ node -v ``` Build: + ```bash npm ci npm run build @@ -47,10 +53,12 @@ npm run build:sea ``` Notes: + - `helpers/build-sea.js` already does ad-hoc signing (`codesign --sign -`) after blob injection so local execution works. - For distribution, replace ad-hoc signature with a real Developer ID certificate. Distribution signing: + ```bash codesign --remove-signature dist/sea/vip codesign --sign "Developer ID Application: " --force --options runtime dist/sea/vip @@ -59,13 +67,17 @@ spctl -a -t exec -vv dist/sea/vip ``` ## Linux (native) + Node/tool setup: + ```bash node -v ``` + (Use Node 22 before build.) Build: + ```bash npm ci npm run build @@ -74,25 +86,30 @@ chmod +x dist/sea/vip ``` Signing guidance: + - Linux does not have a universal OS-enforced Authenticode-style executable signature. - Recommended: publish checksums and detached signatures. Checksum + GPG example: + ```bash sha256sum dist/sea/vip > dist/sea/vip.sha256 gpg --armor --detach-sign dist/sea/vip ``` Cosign blob example: + ```bash cosign sign-blob --yes --output-signature dist/sea/vip.sig dist/sea/vip cosign verify-blob --signature dist/sea/vip.sig dist/sea/vip ``` ## Windows (native) + Use PowerShell or `cmd.exe` on Windows (not WSL) when producing Windows artifacts. Build: + ```powershell npm ci npm run build @@ -101,34 +118,40 @@ npm run build:sea ``` Authenticode signing (SignTool): + ```powershell signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /a .\dist\sea\vip.exe signtool verify /pa /v .\dist\sea\vip.exe ``` If your cert is in a PFX file: + ```powershell signtool sign /f C:\path\cert.pfx /p /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com .\dist\sea\vip.exe ``` ## Windows from WSL + Important: WSL builds Linux binaries by default. - If target is Linux binary: build/sign inside WSL using the Linux flow. - If target is Windows `.exe`: run the build and signing commands in Windows context. From WSL, invoke Windows PowerShell for a Windows-target build: + ```bash WIN_REPO_PATH="$(wslpath -w "$PWD")" powershell.exe -NoProfile -Command "Set-Location '$WIN_REPO_PATH'; npm ci; npm run build; npm run build:sea" ``` Then sign in Windows context: + ```bash powershell.exe -NoProfile -Command "Set-Location '$WIN_REPO_PATH'; signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /a .\\dist\\sea\\vip.exe; signtool verify /pa /v .\\dist\\sea\\vip.exe" ``` ## Release Checklist for Agents + - Confirm Node 22 before SEA build. - Confirm artifact type matches target OS (`vip` vs `vip.exe`). - Run smoke checks on the produced executable. @@ -137,6 +160,7 @@ powershell.exe -NoProfile -Command "Set-Location '$WIN_REPO_PATH'; signtool sign - Record signing method and timestamp authority in release notes. ## GitHub Actions Automation + - Workflow: `.github/workflows/sea-build-sign.yml` - Trigger: manual `workflow_dispatch` - Jobs: native macOS/Linux/Windows SEA builds plus a Windows WSL SEA build diff --git a/package.json b/package.json index 503d56f8a..2ffa7fa16 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test": "npm run lint && npm run check-types && jest --coverage --testPathIgnorePatterns __tests__/devenv-e2e/", "test:e2e:dev-env": "jest -c __tests__/devenv-e2e/jest/jest.config.js", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", - "cmd:format": "prettier '**/*.(js|json|jsx|md|ts|tsx|yml|yaml)'", + "cmd:format": "prettier \"**/*.{js,json,jsx,md,ts,tsx,yml,yaml}\"", "cmd:lint": "eslint --ext 'js,jsx,ts,tsx'", "prepare": "npm run clean && npm run build", "check-types": "tsc",