From 5010ec6737f2838fd138966c77cb0a47b14b6cd4 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sat, 5 Sep 2026 20:06:48 -0700 Subject: [PATCH] chore: prepare npm CLI beta 0.0.4 --- Makefile | 5 +- npm/LICENSE | 202 +++++++++++++++++++++++++++++++++++++++ npm/README.md | 76 +++++++++++++++ npm/cli.js | 11 +++ npm/package.json | 17 ++++ npm/release.js | 8 ++ npm/runtime.js | 119 +++++++++++++++++++++++ npm/test/runtime.test.js | 114 ++++++++++++++++++++++ 8 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 npm/LICENSE create mode 100644 npm/README.md create mode 100644 npm/cli.js create mode 100644 npm/package.json create mode 100644 npm/release.js create mode 100644 npm/runtime.js create mode 100644 npm/test/runtime.test.js diff --git a/Makefile b/Makefile index c043b6a..8611a03 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test build demo e2e verify module-path protocols control +.PHONY: test build demo e2e verify module-path protocols control npm-test test: go test ./... build: @@ -23,3 +23,6 @@ control: verify: module-path protocols control test build go vet ./... + +npm-test: + cd npm && npm test diff --git a/npm/LICENSE b/npm/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/npm/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..012fa8f --- /dev/null +++ b/npm/README.md @@ -0,0 +1,76 @@ +# Rainier CLI beta + +Persistent remote coding-agent sessions from your terminal. This package runs +the native [Rainier](https://github.com/tokencanopy/rainier) CLI; it does not +install or provision a control plane or runner. + +## Run without a global installation + +```sh +npx @tokencanopy/rainier@beta --help +``` + +Or pin this release: `npx @tokencanopy/rainier@0.0.4 --help`. + +## Install for daily use + +```sh +npm install -g @tokencanopy/rainier@beta +rainier version +rainier --help +``` + +Requires Node.js 22 or newer, `tar`, and macOS or Linux on Apple Silicon/ARM64 +or Intel/AMD64. Windows is not supported by this release. + +The first invocation downloads the matching native v0.0.4 binary from GitHub +over HTTPS and verifies pinned SHA-256 hashes for both the archive and binary. +There are no npm dependencies or installation scripts. Subsequent invocations +verify and reuse the cached binary; they do not download it again unless it is +missing or corrupted. `npx` itself may still contact the npm registry. + +The cache is `$XDG_CACHE_HOME/rainier` when `XDG_CACHE_HOME` is an absolute path, +otherwise `~/.cache/rainier`. It must be writable by your user. First use needs +network access to GitHub and its release-asset CDN. The downloader does not +configure an HTTP proxy; use a standalone download if your network requires one. +No Go compiler is needed. Without Node.js, use a +[standalone release binary](https://github.com/tokencanopy/rainier/releases/tag/v0.0.4). +The macOS release is not Developer ID signed or notarized. + +## Connect to Rainier + +You need access to an existing compatible Rainier deployment. For self-hosting, +see the [deployment guide](https://github.com/tokencanopy/rainier/blob/main/docs/deploy-gce.md). + +```sh +rainier login --from-gh --server https://rainier.example.test +rainier doctor +rainier ls +``` + +For hosted pilot access, use the cloud endpoint supplied with your invitation: + +```sh +rainier login --cloud https://cloud.example.test +rainier doctor +``` + +Follow the [first-session guide](https://github.com/tokencanopy/rainier/blob/v0.0.4/docs/cli-quickstart.md) +to select an environment, sign in to Claude Code or Codex, and start a session. +`rainier doctor` checks basic readiness; it does not install agents or prove that +provider credentials work. Hosted agent-credential integration is still in development. + +Publishing this CLI does not imply hosted signup is generally available. +Version 0.0.4 is beta: interfaces may change. npm's `beta` tag selects the beta +package; the package always downloads its own pinned binary, never a moving +GitHub release. To update, rerun the install command above with the desired tag +or version. Run `rainier version` to confirm `rainier v0.0.4`. There is no native CLI auto-updater. + +## Maintainers + +From the repository root, run `make verify` and `make npm-test`. Smoke-test an +`npm pack ./npm` tarball through both `npx --package rainier --help` +and a global installation with a temporary prefix/cache. Publish the verified +tarball using `npm publish --access public --tag beta`. +Never replace v0.0.4 assets or mutate their pinned hashes after publication; +new binaries require a new release and package version. diff --git a/npm/cli.js b/npm/cli.js new file mode 100644 index 0000000..e0d6ce6 --- /dev/null +++ b/npm/cli.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { ensureBinary, runBinary } from './runtime.js'; + +try { + const executable = await ensureBinary(); + await runBinary(executable, process.argv.slice(2)); +} catch (error) { + console.error(`rainier: ${error.message}`); + console.error('Check network access to GitHub and cache-directory permissions. Standalone binaries: https://github.com/tokencanopy/rainier/releases/tag/v0.0.4'); + process.exitCode = 1; +} diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..d6e23c4 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,17 @@ +{ + "name": "@tokencanopy/rainier", + "version": "0.0.4", + "description": "Rainier CLI: persistent remote coding-agent sessions", + "type": "module", + "bin": { "rainier": "cli.js" }, + "files": ["cli.js", "runtime.js", "release.js", "README.md", "LICENSE"], + "engines": { "node": ">=22" }, + "os": ["darwin", "linux"], + "cpu": ["arm64", "x64"], + "license": "Apache-2.0", + "repository": { "type": "git", "url": "git+https://github.com/tokencanopy/rainier.git", "directory": "npm" }, + "homepage": "https://github.com/tokencanopy/rainier#readme", + "bugs": { "url": "https://github.com/tokencanopy/rainier/issues" }, + "publishConfig": { "access": "public", "tag": "beta", "registry": "https://registry.npmjs.org/" }, + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/npm/release.js b/npm/release.js new file mode 100644 index 0000000..96c2b89 --- /dev/null +++ b/npm/release.js @@ -0,0 +1,8 @@ +// Immutable CLI assets built from v0.0.4 (70a37360f66e4cd5311475277594d9ad224454fe). +export const version = '0.0.4'; +export const assets = { + darwin_x64: ['amd64', '9b5f6b339229f7501a854ca064df0537392f4c43ac882b2dcdec5a364e7fd6e2', '0c282136557b4759fa996ec643628acad46d51a28de7fe43065b16def8ce7637'], + darwin_arm64: ['arm64', 'c8e8972d97645a27f539b276ca8530b96642b3454c03fe4b06014713db04217a', '6d7649667adf72ecd65c1900851610406b80d97eea85503d1d02a254c3e94b3f'], + linux_x64: ['amd64', '39f93acdc16ae9e9c123451e3c53d0e764ad967aec4a286f2b649a2e07cdbdbe', '538ebed1a99ac43571ef06abe0fcb18085be8f6ad568605b0f0e6318867e356e'], + linux_arm64: ['arm64', 'b1489d22d4f4ceab5540923dc62fdfd1c8423cb04c16849ef23bf8f82fe745ea', '65828c3c58e72874e67e44ca55064420414c5ec35cfc16372dcc68da0908faa9'], +}; diff --git a/npm/runtime.js b/npm/runtime.js new file mode 100644 index 0000000..5852200 --- /dev/null +++ b/npm/runtime.js @@ -0,0 +1,119 @@ +import { createHash } from 'node:crypto'; +import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { assets, version } from './release.js'; + +const exec = promisify(execFile); +const maxBytes = 16 * 1024 * 1024; +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +export function selectRelease(platform = process.platform, arch = process.arch) { + const asset = assets[`${platform}_${arch}`]; + if (!asset) throw new Error(`Unsupported platform: ${platform}/${arch}. Rainier supports macOS and Linux on arm64 and x64.`); + const [goArch, archiveHash, binaryHash] = asset; + return { version, platform, arch, archiveHash, binaryHash, + url: `https://github.com/tokencanopy/rainier/releases/download/v${version}/rainier_${version}_${platform}_${goArch}.tar.gz` }; +} + +export async function download(url, { fetchImpl = fetch, timeoutMs = 60_000, limit = maxBytes } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error('Download timed out')), timeoutMs); + try { + for (let redirects = 0; redirects <= 5; redirects++) { + if (new URL(url).protocol !== 'https:') throw new Error('Download requires HTTPS'); + const response = await fetchImpl(url, { redirect: 'manual', signal: controller.signal }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel(); + const location = response.headers.get('location'); + if (!location) throw new Error('Download redirect has no location'); + url = new URL(location, url).href; + continue; + } + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Download failed: HTTP ${response.status}`); + } + if (Number(response.headers.get('content-length')) > limit) { + await response.body?.cancel(); + throw new Error('Download exceeds size limit'); + } + const chunks = []; + let size = 0; + if (!response.body) throw new Error('Download has no body'); + for await (const chunk of response.body) { + size += chunk.length; + if (size > limit) throw new Error('Download exceeds size limit'); + chunks.push(chunk); + } + return Buffer.concat(chunks); + } + throw new Error('Too many download redirects'); + } finally { + clearTimeout(timer); + } +} + +function defaultCacheRoot() { + const xdg = process.env.XDG_CACHE_HOME; + return join(xdg && isAbsolute(xdg) ? xdg : join(homedir(), '.cache'), 'rainier'); +} + +async function verifiedFile(path, expectedHash) { + try { + const stat = await lstat(path); + return stat.isFile() && stat.size <= maxBytes && digest(await readFile(path)) === expectedHash; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +export async function ensureBinary({ release = selectRelease(), cacheRoot = defaultCacheRoot(), download: fetchArchive = download } = {}) { + const dir = join(cacheRoot, `${release.version}-${release.platform}-${release.arch}`); + const executable = join(dir, 'rainier'); + await mkdir(dir, { recursive: true, mode: 0o700 }); + if (await verifiedFile(executable, release.binaryHash)) { + await chmod(executable, 0o755); + return executable; + } + const stage = await mkdtemp(join(dir, '.install-')); + try { + const bytes = await fetchArchive(release.url); + if (digest(bytes) !== release.archiveHash) throw new Error('Archive checksum mismatch; refusing to execute downloaded code'); + const archive = join(stage, 'release.tar.gz'); + await writeFile(archive, bytes, { mode: 0o600 }); + await exec('tar', ['-xzf', archive, '-C', stage, 'rainier'], { timeout: 10_000 }); + const binary = join(stage, 'rainier'); + if (!await verifiedFile(binary, release.binaryHash)) throw new Error('Binary checksum mismatch; refusing to execute downloaded code'); + await chmod(binary, 0o755); + // Each writer publishes the same verified bytes; rename never exposes a partial file. + await rename(binary, executable); + return executable; + } finally { + await rm(stage, { recursive: true, force: true }); + } +} + +export async function runBinary(executable, args) { + const child = spawn(executable, args, { stdio: 'inherit' }); + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; + const handlers = signals.map(signal => { + const handler = () => child.kill(signal); + process.on(signal, handler); + return handler; + }); + let result; + try { + result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + } finally { + signals.forEach((signal, i) => process.removeListener(signal, handlers[i])); + } + if (result.signal) process.kill(process.pid, result.signal); + else process.exitCode = result.code ?? 1; +} diff --git a/npm/test/runtime.test.js b/npm/test/runtime.test.js new file mode 100644 index 0000000..9c135ce --- /dev/null +++ b/npm/test/runtime.test.js @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync, spawn } from 'node:child_process'; +import { selectRelease, ensureBinary, download } from '../runtime.js'; + +const hash = value => createHash('sha256').update(value).digest('hex'); +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'rainier-npm-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const source = join(dir, 'source'); + await mkdir(source); + const binary = '#!/bin/sh\nprintf "%s\\n" "$@"\nexit 7\n'; + await writeFile(join(source, 'rainier'), binary, { mode: 0o755 }); + const archive = join(dir, 'source.tar.gz'); + execFileSync('tar', ['-czf', archive, '-C', source, 'rainier']); + const bytes = await readFile(archive); + const release = { version: '0.0.4', platform: 'linux', arch: 'x64', url: 'https://example.test/release.tar.gz', archiveHash: hash(bytes), binaryHash: hash(binary) }; + return { dir, binary, bytes, release, cache: join(dir, 'cache'), download: async () => bytes }; +} + +test('maps all supported platforms to immutable v0.0.4 assets', () => { + for (const platform of ['darwin', 'linux']) { + for (const [arch, goArch] of [['arm64', 'arm64'], ['x64', 'amd64']]) { + const release = selectRelease(platform, arch); + assert.equal(release.url, `https://github.com/tokencanopy/rainier/releases/download/v0.0.4/rainier_0.0.4_${platform}_${goArch}.tar.gz`); + assert.match(release.archiveHash, /^[a-f0-9]{64}$/); + assert.match(release.binaryHash, /^[a-f0-9]{64}$/); + } + } + assert.throws(() => selectRelease('win32', 'x64'), /Unsupported platform: win32\/x64/); + assert.throws(() => selectRelease('linux', 'ia32'), /Unsupported platform/); +}); + +test('installs verified executable, reuses offline, repairs corrupted cache', async t => { + const f = await fixture(t); + const options = { release: f.release, cacheRoot: f.cache, download: f.download }; + const executable = await ensureBinary(options); + assert.equal(await readFile(executable, 'utf8'), f.binary); + assert.equal(await ensureBinary({ ...options, download: () => { throw Error('offline'); } }), executable); + await writeFile(executable, 'corrupt'); + await ensureBinary(options); + assert.equal(await readFile(executable, 'utf8'), f.binary); +}); + +test('rejects archive and extracted binary checksum mismatches', async t => { + const f = await fixture(t); + await assert.rejects(ensureBinary({ release: f.release, cacheRoot: f.cache, download: async () => Buffer.from('bad') }), /Archive checksum mismatch/); + await assert.rejects(ensureBinary({ release: { ...f.release, binaryHash: '0'.repeat(64) }, cacheRoot: f.cache, download: f.download }), /Binary checksum mismatch/); +}); + +test('concurrent first runs publish only a complete verified executable', async t => { + const f = await fixture(t); + const paths = await Promise.all(Array.from({ length: 6 }, () => ensureBinary({ release: f.release, cacheRoot: f.cache, download: f.download }))); + assert.equal(new Set(paths).size, 1); + assert.equal(await readFile(paths[0], 'utf8'), f.binary); +}); + +test('forwards exact arguments without shell evaluation and preserves exit code', async t => { + const f = await fixture(t); + const executable = join(f.dir, 'args.js'); + await writeFile(executable, '#!/usr/bin/env node\nprocess.stdout.write(JSON.stringify(process.argv.slice(2))); process.exit(7);\n'); + await chmod(executable, 0o755); + const script = `import {runBinary} from ${JSON.stringify(new URL('../runtime.js', import.meta.url).href)}; await runBinary(${JSON.stringify(executable)}, ['space here', '$(echo unsafe)', '--flag=value']);`; + const child = spawn(process.execPath, ['--input-type=module', '-e', script]); + let stdout = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + const code = await new Promise(resolve => child.on('close', resolve)); + assert.equal(code, 7); + assert.deepEqual(JSON.parse(stdout), ['space here', '$(echo unsafe)', '--flag=value']); +}); + +test('downloader rejects insecure URLs before any network request', async () => { + await assert.rejects(download('http://example.test/file'), /HTTPS/); +}); + +test('downloader follows HTTPS redirects and returns the exact bytes', async () => { + const responses = [new Response(null, { status: 302, headers: { location: 'https://cdn.example.test/asset' } }), new Response('archive')]; + assert.equal((await download('https://example.test/file', { fetchImpl: async () => responses.shift() })).toString(), 'archive'); +}); + +test('downloader bounds redirects and rejects HTTPS downgrades and HTTP errors', async () => { + const redirect = location => async () => new Response(null, { status: 302, headers: { location } }); + await assert.rejects(download('https://example.test/file', { fetchImpl: redirect('/loop') }), /Too many download redirects/); + await assert.rejects(download('https://example.test/file', { fetchImpl: redirect('http://example.test/file') }), /HTTPS/); + await assert.rejects(download('https://example.test/file', { fetchImpl: async () => new Response(null, { status: 404 }) }), /HTTP 404/); + await assert.rejects(download('https://example.test/file', { fetchImpl: async () => new Response(null, { status: 302 }) }), /no location/); +}); + +test('downloader bounds advertised and streamed response sizes', async () => { + await assert.rejects(download('https://example.test/file', { limit: 3, fetchImpl: async () => new Response('large', { headers: { 'content-length': '5' } }) }), /size limit/); + await assert.rejects(download('https://example.test/file', { limit: 3, fetchImpl: async () => new Response('large') }), /size limit/); +}); + +test('downloader aborts a stalled request', async () => { + const fetchImpl = async (_url, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + await assert.rejects(download('https://example.test/file', { fetchImpl, timeoutMs: 10 }), /timed out/); +}); + +test('forwards termination to the child and preserves signal exit', async t => { + const f = await fixture(t); + const script = `import {runBinary} from ${JSON.stringify(new URL('../runtime.js', import.meta.url).href)}; await runBinary(process.execPath, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)']);`; + const child = spawn(process.execPath, ['--input-type=module', '-e', script]); + t.after(() => child.kill('SIGKILL')); + const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal }))); + await new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('error', reject); }); + child.kill('SIGTERM'); + assert.deepEqual(await exit, { code: null, signal: 'SIGTERM' }); +});