diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5611ecc4..87ffb657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,6 +194,21 @@ jobs: exit 1 fi + test-launcher: + name: Launcher Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Run launcher tests + run: node --test npm/*.test.js + release: name: Build and Publish Release if: startsWith(github.ref, 'refs/tags/') @@ -202,6 +217,7 @@ jobs: - goreleaser-check - test-unit - test-integration + - test-launcher runs-on: ubuntu-latest permissions: contents: write @@ -240,16 +256,31 @@ jobs: node-version: "20" registry-url: "https://registry.npmjs.org" + # Build the platform packages and main wrapper package from the + # GoReleaser output. This is the same tool the previous + # evg4b/goreleaser-npm-publisher-action wrapped, split into an explicit + # build step so we can swap in our own launcher before publishing. + - name: Build npm packages + run: > + npx --yes goreleaser-npm-publisher@1.5.0 build + --project . + --prefix @localstack + --license Apache-2.0 + --description "LocalStack CLI v2 - Start and manage LocalStack emulators" + --files README.md LICENSE + --keywords localstack cli aws emulator docker + + # Replace the auto-generated wrapper with our launcher, which forwards + # SIGINT/SIGTERM/SIGHUP to the Go binary (DEVX-942). The generated wrapper + # spawns the binary without signal handlers, so a programmatic kill of the + # Node process would orphan the child, including mid-flight container starts. + - name: Install signal-forwarding launcher + run: cp npm/launcher.js dist/npm/lstk/index.js + - name: Publish to NPM - uses: evg4b/goreleaser-npm-publisher-action@v1 - with: - token: ${{ secrets.NPM_AUTH_TOKEN }} - prefix: "@localstack" - license: Apache-2.0 - description: "LocalStack CLI v2 - Start and manage LocalStack emulators" - keywords: | - localstack - cli - aws - emulator - docker + run: | + for dir in dist/npm/lstk-*/ dist/npm/lstk/; do + npm publish "$dir" --access public + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index cdde31cf..ccd8f47d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,12 @@ A REF is parsed by helpers in `internal/snapshot/destination.go`: **Auto-load on start.** A `[[containers]]` block (AWS only) can set `snapshot = "pod:my-baseline"` (any load REF) to auto-load that snapshot after the emulator starts. The loader runs only when the emulator is freshly started this run (skipped when already running), mirroring v1's `AUTO_LOAD_POD`. `lstk start --snapshot REF` overrides the configured REF for one run; `lstk start --no-snapshot` skips it. Resolution lives in `resolveStartSnapshotRef`/`newSnapshotAutoLoader` in `cmd/snapshot.go`; the loader is threaded into the non-interactive start in `cmd/root.go` and into the TUI via `ui.RunOptions.PostStart`. `snapshot save` never writes back into config — the `snapshot` field is manual. +# NPM Distribution + +`@localstack/lstk` is published as a thin Node wrapper package whose `bin` is `npm/launcher.js`. The wrapper resolves the prebuilt Go binary from the platform-specific optional dependency npm installed for the host, execs it, and **forwards `SIGINT`/`SIGTERM`/`SIGHUP`** so a programmatic `kill` of the Node process tears down the Go child instead of orphaning it (the auto-generated wrapper from `goreleaser-npm-publisher` installed no signal handlers). The launcher also propagates the child's exit code / terminating signal. Tests in `npm/launcher.test.js` run via `node --test` in the `test-launcher` CI job. + +The release job (`.github/workflows/ci.yml`) builds the npm packages with `goreleaser-npm-publisher build`, overwrites the generated `dist/npm/lstk/index.js` with `npm/launcher.js`, then `npm publish`es each package — replacing the previous single `evg4b/goreleaser-npm-publisher-action` step. + # Code Style - Don't add comments for self-explanatory code. Only comment when the "why" isn't obvious from the code itself. diff --git a/npm/launcher.js b/npm/launcher.js new file mode 100644 index 00000000..58288e22 --- /dev/null +++ b/npm/launcher.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +'use strict'; + +// Launcher published as the `bin` of the main `@localstack/lstk` npm package. +// It locates the prebuilt Go binary shipped in the platform-specific optional +// dependency and execs it, forwarding the parent process' arguments and exit +// status. +// +// Crucially it forwards termination signals to the child. Without this a +// programmatic `kill ` (e.g. from a supervisor or test harness) would +// terminate this Node wrapper but orphan the Go binary, leaving mid-flight +// container starts running. Interactive Ctrl-C already reaches the child via +// the TTY process group; the signal forwarding covers the non-interactive case. + +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']; + +// Resolve the prebuilt binary from the optional dependency that npm installed +// for this host. npm only installs the optional dependency whose `os`/`cpu` +// match the current platform, so we pick the first one that both resolves and +// matches. +function resolveBinaryPath(packageDir) { + const manifest = require(path.join(packageDir, 'package.json')); + const deps = Object.keys(manifest.optionalDependencies || {}); + + for (const dep of deps) { + let depManifestPath; + try { + depManifestPath = require.resolve(path.join(dep, 'package.json'), { + paths: [packageDir], + }); + } catch { + continue; // optional dependency for another platform, not installed + } + + const depManifest = require(depManifestPath); + const oses = [].concat(depManifest.os || []); + const cpus = [].concat(depManifest.cpu || []); + if (oses.length && !oses.includes(process.platform)) continue; + if (cpus.length && !cpus.includes(process.arch)) continue; + + const bin = depManifest.bin; + const binFile = typeof bin === 'string' ? bin : Object.values(bin || {})[0]; + if (!binFile) continue; + + return path.join(path.dirname(depManifestPath), binFile); + } + + return null; +} + +// Forward termination signals to the child while it is running. Returns a +// function that detaches the handlers so the wrapper can re-raise a signal +// against itself without re-entering them. +function forwardSignals(child) { + const handlers = new Map(); + for (const signal of FORWARDED_SIGNALS) { + const handler = () => { + try { + child.kill(signal); + } catch { + // child already exited; nothing to forward to + } + }; + handlers.set(signal, handler); + process.on(signal, handler); + } + + return () => { + for (const [signal, handler] of handlers) { + process.removeListener(signal, handler); + } + }; +} + +function main() { + const binaryPath = resolveBinaryPath(__dirname); + if (!binaryPath) { + process.stderr.write( + `lstk: no prebuilt binary found for ${process.platform}-${process.arch}\n`, + ); + process.exit(1); + } + + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + env: process.env, + }); + const stopForwarding = forwardSignals(child); + + child.on('error', (err) => { + stopForwarding(); + process.stderr.write(`lstk: failed to launch ${binaryPath}: ${err.message}\n`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + stopForwarding(); + if (signal) { + // Re-raise without our handlers so the wrapper's own exit status reflects + // the signal that terminated the child. + process.kill(process.pid, signal); + return; + } + process.exit(code === null ? 1 : code); + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { resolveBinaryPath, forwardSignals, FORWARDED_SIGNALS }; diff --git a/npm/launcher.test.js b/npm/launcher.test.js new file mode 100644 index 00000000..3abdea32 --- /dev/null +++ b/npm/launcher.test.js @@ -0,0 +1,104 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); + +const { resolveBinaryPath } = require('./launcher'); + +const LAUNCHER = path.join(__dirname, 'launcher.js'); + +// Build a throwaway npm package layout mirroring what the publisher ships: a +// main package containing the launcher plus a platform-specific optional +// dependency that carries the (fake) binary. +function makePackage(t, { binarySource, os: pkgOs, cpu, withDep = true }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lstk-launcher-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + fs.copyFileSync(LAUNCHER, path.join(root, 'index.js')); + + const depName = 'lstk-fake-platform'; + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'lstk', + bin: { lstk: 'index.js' }, + optionalDependencies: withDep ? { [depName]: '0.0.0' } : {}, + }), + ); + + if (withDep) { + const depDir = path.join(root, 'node_modules', depName); + fs.mkdirSync(depDir, { recursive: true }); + fs.writeFileSync( + path.join(depDir, 'package.json'), + JSON.stringify({ + name: depName, + version: '0.0.0', + bin: { lstk: 'lstk' }, + os: [pkgOs ?? process.platform], + cpu: [cpu ?? process.arch], + }), + ); + const binaryPath = path.join(depDir, 'lstk'); + fs.writeFileSync(binaryPath, binarySource); + fs.chmodSync(binaryPath, 0o755); + } + + return root; +} + +test('resolveBinaryPath finds the matching platform binary', (t) => { + const root = makePackage(t, { binarySource: '#!/bin/sh\nexit 0\n' }); + const resolved = resolveBinaryPath(root); + assert.strictEqual( + resolved, + path.join(root, 'node_modules', 'lstk-fake-platform', 'lstk'), + ); +}); + +test('resolveBinaryPath returns null when no optional dependency matches', (t) => { + const root = makePackage(t, { + binarySource: '#!/bin/sh\nexit 0\n', + os: 'nonexistent-os', + }); + assert.strictEqual(resolveBinaryPath(root), null); +}); + +// The regression test for DEVX-942: a signal sent to the wrapper must reach the +// child, and the child's exit status must propagate back out. +test('forwards SIGTERM to the child and propagates its exit code', { skip: process.platform === 'win32' }, async (t) => { + const flag = path.join(os.tmpdir(), `lstk-sigterm-${process.pid}-${Date.now()}`); + t.after(() => fs.rmSync(flag, { force: true })); + + const binarySource = `#!/usr/bin/env node +process.on('SIGTERM', () => { + require('fs').writeFileSync(${JSON.stringify(flag)}, 'SIGTERM'); + process.exit(42); +}); +process.stdout.write('ready\\n'); +setInterval(() => {}, 1000); +`; + const root = makePackage(t, { binarySource }); + + const child = spawn(process.execPath, [path.join(root, 'index.js')], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + + // Wait until the fake binary has installed its handler and is running. + await new Promise((resolve) => { + child.stdout.on('data', (chunk) => { + if (chunk.toString().includes('ready')) resolve(); + }); + }); + + child.kill('SIGTERM'); + const [code] = await once(child, 'exit'); + + assert.strictEqual(code, 42, 'wrapper should exit with the child exit code'); + assert.strictEqual(fs.readFileSync(flag, 'utf8'), 'SIGTERM', 'child should receive SIGTERM'); +});