Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 43 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/')
Expand All @@ -202,6 +217,7 @@ jobs:
- goreleaser-check
- test-unit
- test-integration
- test-launcher
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down Expand Up @@ -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 }}
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
115 changes: 115 additions & 0 deletions npm/launcher.js
Original file line number Diff line number Diff line change
@@ -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 <lstk-pid>` (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 };
104 changes: 104 additions & 0 deletions npm/launcher.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading