From 0382636b47957ccb2f2a4ae78a20ca3f37409f9d Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 23 Sep 2026 17:12:04 +0200 Subject: [PATCH 1/3] feat: convert the powershell tool to a typescript installer Co-Authored-By: Claude Opus 5.5 --- src/cli/tools/dotnet/powershell.spec.ts | 172 ++++++++++++++++++ src/cli/tools/dotnet/powershell.ts | 104 ++++++++++- .../containerbase/tools/v2/powershell.sh | 44 ----- 3 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 src/cli/tools/dotnet/powershell.spec.ts delete mode 100644 src/usr/local/containerbase/tools/v2/powershell.sh diff --git a/src/cli/tools/dotnet/powershell.spec.ts b/src/cli/tools/dotnet/powershell.spec.ts new file mode 100644 index 0000000000..36f6e06e36 --- /dev/null +++ b/src/cli/tools/dotnet/powershell.spec.ts @@ -0,0 +1,172 @@ +import fs from 'node:fs/promises'; +import { arch } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { CompressionService, LinkToolService } from '../../services/index.ts'; +import { getDistro } from '../../utils/index.ts'; +import { + PowershellInstallService, + PowershellPrepareService, +} from './powershell.ts'; +import { scope } from '~test/http-mock.ts'; +import { ensurePaths } from '~test/path.ts'; +import { checksum, toolContext } from '~test/tool.ts'; + +const { execaMock } = vi.hoisted(() => ({ execaMock: vi.fn() })); +vi.mock('execa', () => ({ execa: execaMock })); +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + arch: vi.fn(() => 'x64'), +})); +vi.mock('../../utils/index.ts', async (importActual) => ({ + ...(await importActual()), + getDistro: vi.fn(), +})); + +const baseUrl = 'https://github.com'; +const releaseUrl = '/PowerShell/PowerShell/releases/download'; +const archive = 'powershell archive'; +const bom = String.fromCharCode(0xfeff); + +describe('cli/tools/dotnet/powershell', () => { + beforeAll(async () => { + await ensurePaths(['tmp', 'opt/containerbase/bin']); + }); + + beforeEach(() => { + vi.mocked(arch).mockReturnValue('x64'); + // CI configures an apt proxy, which `AptService` would write to `/etc` + vi.stubEnv('APT_HTTP_PROXY', undefined); + execaMock.mockResolvedValue({ failed: false }); + }); + + describe('PowershellPrepareService', () => { + test.each([ + { code: 'jammy', pkgs: ['libicu70', 'libssl3'] }, + { code: 'noble', pkgs: ['libicu74', 'libssl3t64'] }, + { code: 'resolute', pkgs: ['libbrotli1', 'libicu78', 'libssl3t64'] }, + ])('prepare on $code', async ({ code, pkgs }) => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: code, + versionId: '24.04', + }); + const { svc } = await toolContext(PowershellPrepareService); + + await expect(svc.prepare()).resolves.toBeUndefined(); + + expect(execaMock).toHaveBeenCalledWith( + 'apt-get', + expect.arrayContaining(['libc6', 'zlib1g', ...pkgs]), + ); + }); + + test('prepare: throws on an unsupported distro', async () => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: 'focal', + versionId: '20.04', + }); + const { svc } = await toolContext(PowershellPrepareService); + + await expect(svc.prepare()).rejects.toThrow( + "Tool 'powershell' not supported on: focal!", + ); + }); + }); + + describe('PowershellInstallService', () => { + test.each([ + { + hostArch: 'x64', + toolArch: 'x64', + version: '7.6.6', + encoding: 'utf16le', + }, + { + hostArch: 'arm64', + toolArch: 'arm64', + version: '7.2.8', + encoding: 'utf8', + }, + ] as const)( + 'install $version on $toolArch', + async ({ hostArch, toolArch, version, encoding }) => { + vi.mocked(arch).mockReturnValue(hostArch); + const { svc, pathSvc } = await toolContext(PowershellInstallService); + const filename = `powershell-${version}-linux-${toolArch}.tar.gz`; + const hashes = `${bom}${checksum('other')} *powershell-${version}-osx-${toolArch}.tar.gz\r\n${checksum(archive)} *${filename}\r\n`; + scope(baseUrl) + .get(`${releaseUrl}/v${version}/hashes.sha256`) + .reply(200, Buffer.from(hashes, encoding)) + .get(`${releaseUrl}/v${version}/${filename}`) + .reply(200, archive); + const path = pathSvc.versionedToolPath('powershell', version); + const extract = vi + .spyOn(CompressionService.prototype, 'extract') + .mockImplementationOnce(({ cwd }) => + fs.writeFile(join(cwd, 'pwsh'), 'pwsh', { mode: 0o644 }), + ); + + await expect(svc.install(version)).resolves.toBeUndefined(); + + expect(extract).toHaveBeenCalledExactlyOnceWith({ + file: expect.stringContaining(filename), + cwd: path, + }); + expect((await fs.stat(join(path, 'pwsh'))).mode & 0o777).toBe(0o775); + }, + ); + + test('install: rejects a missing checksum', async () => { + const { svc } = await toolContext(PowershellInstallService); + scope(baseUrl) + .get(`${releaseUrl}/v7.6.4/hashes.sha256`) + .reply( + 200, + `${checksum('other')} *powershell-7.6.4-linux-arm64.tar.gz\n`, + ); + + await expect(svc.install('7.6.4')).rejects.toThrow( + 'Checksum for powershell-7.6.4-linux-x64.tar.gz not found', + ); + }); + + test('install: rejects a checksum mismatch', async () => { + const { svc } = await toolContext(PowershellInstallService); + const filename = 'powershell-7.6.5-linux-x64.tar.gz'; + scope(baseUrl) + .get(`${releaseUrl}/v7.6.5/hashes.sha256`) + .reply(200, `${checksum('other')} *${filename}\n`) + .get(`${releaseUrl}/v7.6.5/${filename}`) + .times(3) + .reply(200, archive); + + await expect(svc.install('7.6.5')).rejects.toThrow('download failed'); + }); + + test('link', async () => { + const { svc, pathSvc } = await toolContext(PowershellInstallService); + const spy = vi.spyOn(LinkToolService.prototype, 'shellwrapper'); + + await expect(svc.link('7.6.6')).resolves.toBeUndefined(); + + expect(spy).toHaveBeenCalledExactlyOnceWith('powershell', { + name: 'pwsh', + srcDir: pathSvc.versionedToolPath('powershell', '7.6.6'), + }); + }); + + test('runs the tool test', async () => { + const { svc } = await toolContext(PowershellInstallService); + + await expect(svc.test('7.6.6')).resolves.toBeUndefined(); + + expect(execaMock).toHaveBeenCalledWith( + 'pwsh', + ['-version'], + expect.any(Object), + ); + }); + }); +}); diff --git a/src/cli/tools/dotnet/powershell.ts b/src/cli/tools/dotnet/powershell.ts index 8bf3d9db78..791f46c2ef 100644 --- a/src/cli/tools/dotnet/powershell.ts +++ b/src/cli/tools/dotnet/powershell.ts @@ -1,18 +1,106 @@ -import { injectFromHierarchy, injectable } from 'inversify'; -import { V2ToolInstallService } from '../../install-tool/install-legacy-tool.service.ts'; -import { V2ToolPrepareService } from '../../prepare-tool/prepare-legacy-tools.service.ts'; -import { v2Tool } from '../../utils/v2-tool.ts'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +import { inject, injectFromHierarchy, injectable } from 'inversify'; +import { BaseInstallService } from '../../install-tool/base-install.service.ts'; +import { BasePrepareService } from '../../prepare-tool/base-prepare.service.ts'; +import { AptService } from '../../services/index.ts'; +import { getDistro } from '../../utils/index.ts'; + +/** + * The distro specific dependencies. + * @see {@link https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install?tabs=dotnet10&pivots=os-linux-ubuntu-2204#dependencies-4} + */ +const distroPackages: Record = { + jammy: ['libicu70', 'libssl3'], + noble: ['libicu74', 'libssl3t64'], + resolute: ['libbrotli1', 'libicu78', 'libssl3t64'], +}; @injectable() @injectFromHierarchy() -@v2Tool('powershell') -export class PowershellPrepareService extends V2ToolPrepareService { +export class PowershellPrepareService extends BasePrepareService { + @inject(AptService) + private readonly aptSvc!: AptService; + override readonly name = 'powershell'; + + override async prepare(): Promise { + const distro = await getDistro(); + const packages = distroPackages[distro.versionCode]; + if (!packages) { + throw new Error( + `Tool '${this.name}' not supported on: ${distro.versionCode}! Please use ubuntu 'jammy', 'noble' or 'resolute'.`, + ); + } + + await this.aptSvc.install( + 'libc6', + 'libgcc-s1', + 'libgssapi-krb5-2', + 'libstdc++6', + 'tzdata', + 'zlib1g', + ...packages, + ); + } } @injectable() @injectFromHierarchy() -@v2Tool('powershell') -export class PowershellInstallService extends V2ToolInstallService { +export class PowershellInstallService extends BaseInstallService { override readonly name = 'powershell'; + + private get ghArch(): string { + return this.envSvc.arch === 'arm64' ? 'arm64' : 'x64'; + } + + override async install(version: string): Promise { + const baseUrl = `https://github.com/PowerShell/PowerShell/releases/download/v${version}/`; + const filename = `${this.name}-${version}-linux-${this.ghArch}.tar.gz`; + + const checksumFile = await this.http.download({ + url: `${baseUrl}hashes.sha256`, + }); + const expectedChecksum = readChecksums(await fs.readFile(checksumFile)) + .split('\n') + .map((l) => l.trim()) + .find((l) => l.endsWith(filename)) + ?.split(' ')[0]; + if (!expectedChecksum) { + throw new Error(`Checksum for ${filename} not found`); + } + + const file = await this.http.download({ + url: `${baseUrl}${filename}`, + checksumType: 'sha256', + expectedChecksum, + }); + + await this.pathSvc.ensureToolPath(this.name); + + const path = await this.pathSvc.createVersionedToolPath(this.name, version); + await this.compress.extract({ file, cwd: path }); + + // Happened on v7.3.0 + await fs.chmod(join(path, 'pwsh'), this.envSvc.umask); + } + + override async link(version: string): Promise { + await this.shellwrapper({ + name: 'pwsh', + srcDir: this.pathSvc.versionedToolPath(this.name, version), + }); + } + + override async test(_version: string): Promise { + await this._spawn('pwsh', ['-version']); + } +} + +/** The checksum file is UTF-16LE with a BOM. */ +function readChecksums(buf: Buffer): string { + if (buf[0] === 0xff && buf[1] === 0xfe) { + return buf.toString('utf16le'); + } + return buf.toString('utf8'); } diff --git a/src/usr/local/containerbase/tools/v2/powershell.sh b/src/usr/local/containerbase/tools/v2/powershell.sh deleted file mode 100644 index 3bf1c20e0a..0000000000 --- a/src/usr/local/containerbase/tools/v2/powershell.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash - -function prepare_tool() { - local version_codename - - version_codename="$(get_distro)" - case "${version_codename}" in - # https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install?tabs=dotnet10&pivots=os-linux-ubuntu-2204#dependencies-4 - "jammy") apt_install libc6 libgcc-s1 libgssapi-krb5-2 libicu70 libssl3 libstdc++6 tzdata zlib1g;; - "noble") apt_install libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g;; - "resolute") apt_install libbrotli1 libc6 libgcc-s1 libgssapi-krb5-2 libicu78 libssl3t64 libstdc++6 tzdata zlib1g;; - *) - echo "Tool '${TOOL_NAME}' not supported on: ${version_codename}! Please use ubuntu 'noble' or 'resolute'." >&2 - exit 1 - ;; - esac -} - -function install_tool () { - local file - local versioned_tool_path - local arch=linux-x64 - - if [[ "${ARCHITECTURE}" = "aarch64" ]]; then - arch=linux-arm64 - fi - - file=$(get_from_url "https://github.com/PowerShell/PowerShell/releases/download/v${TOOL_VERSION}/powershell-${TOOL_VERSION}-${arch}.tar.gz") - - versioned_tool_path=$(create_versioned_tool_path) - bsdtar -C "${versioned_tool_path}" -xzf "${file}" - # Happened on v7.3.0 - if [[ ! -x "${versioned_tool_path}/pwsh" ]]; then - chmod +x "${versioned_tool_path}/pwsh" - fi -} - -function link_tool () { - shell_wrapper pwsh "$(find_versioned_tool_path)" -} - -function test_tool () { - pwsh -version -} From 3a73d98a3f10cf60ba150d675020b33869d8ed54 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 24 Sep 2026 08:49:40 +0200 Subject: [PATCH 2/3] docs(powershell): document the install services Co-Authored-By: Claude Opus 5.5 --- src/cli/tools/dotnet/powershell.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cli/tools/dotnet/powershell.ts b/src/cli/tools/dotnet/powershell.ts index 791f46c2ef..3bf9a04a27 100644 --- a/src/cli/tools/dotnet/powershell.ts +++ b/src/cli/tools/dotnet/powershell.ts @@ -24,6 +24,11 @@ export class PowershellPrepareService extends BasePrepareService { override readonly name = 'powershell'; + /** + * Installs the apt packages powershell needs on the current ubuntu release. + * + * @throws on an unsupported distro + */ override async prepare(): Promise { const distro = await getDistro(); const packages = distroPackages[distro.versionCode]; @@ -50,10 +55,15 @@ export class PowershellPrepareService extends BasePrepareService { export class PowershellInstallService extends BaseInstallService { override readonly name = 'powershell'; + /** The architecture name used by the powershell release assets. */ private get ghArch(): string { return this.envSvc.arch === 'arm64' ? 'arm64' : 'x64'; } + /** + * Downloads the powershell archive from GitHub, verified against the + * release's `hashes.sha256`, and extracts it into the versioned tool path. + */ override async install(version: string): Promise { const baseUrl = `https://github.com/PowerShell/PowerShell/releases/download/v${version}/`; const filename = `${this.name}-${version}-linux-${this.ghArch}.tar.gz`; @@ -85,6 +95,7 @@ export class PowershellInstallService extends BaseInstallService { await fs.chmod(join(path, 'pwsh'), this.envSvc.umask); } + /** Links the `pwsh` binary into the global bin folder. */ override async link(version: string): Promise { await this.shellwrapper({ name: 'pwsh', @@ -92,12 +103,16 @@ export class PowershellInstallService extends BaseInstallService { }); } + /** Checks that `pwsh -version` runs. */ override async test(_version: string): Promise { await this._spawn('pwsh', ['-version']); } } -/** The checksum file is UTF-16LE with a BOM. */ +/** + * Decodes the release's checksum file, which is UTF-16LE with a BOM, falling + * back to UTF-8 without one. + */ function readChecksums(buf: Buffer): string { if (buf[0] === 0xff && buf[1] === 0xfe) { return buf.toString('utf16le'); From 3003a004fa7295ce19018c617638c93571a89199 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 24 Sep 2026 11:56:42 +0200 Subject: [PATCH 3/3] refactor(powershell): use the shared checksum helper Co-Authored-By: Claude Opus 5.5 --- src/cli/tools/dotnet/powershell.spec.ts | 2 +- src/cli/tools/dotnet/powershell.ts | 26 ++++--------------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/src/cli/tools/dotnet/powershell.spec.ts b/src/cli/tools/dotnet/powershell.spec.ts index 36f6e06e36..067ff6b4d7 100644 --- a/src/cli/tools/dotnet/powershell.spec.ts +++ b/src/cli/tools/dotnet/powershell.spec.ts @@ -128,7 +128,7 @@ describe('cli/tools/dotnet/powershell', () => { ); await expect(svc.install('7.6.4')).rejects.toThrow( - 'Checksum for powershell-7.6.4-linux-x64.tar.gz not found', + `Checksum not found in ${baseUrl}${releaseUrl}/v7.6.4/hashes.sha256 for powershell-7.6.4-linux-x64.tar.gz`, ); }); diff --git a/src/cli/tools/dotnet/powershell.ts b/src/cli/tools/dotnet/powershell.ts index 3bf9a04a27..22c1a89d77 100644 --- a/src/cli/tools/dotnet/powershell.ts +++ b/src/cli/tools/dotnet/powershell.ts @@ -68,17 +68,10 @@ export class PowershellInstallService extends BaseInstallService { const baseUrl = `https://github.com/PowerShell/PowerShell/releases/download/v${version}/`; const filename = `${this.name}-${version}-linux-${this.ghArch}.tar.gz`; - const checksumFile = await this.http.download({ - url: `${baseUrl}hashes.sha256`, - }); - const expectedChecksum = readChecksums(await fs.readFile(checksumFile)) - .split('\n') - .map((l) => l.trim()) - .find((l) => l.endsWith(filename)) - ?.split(' ')[0]; - if (!expectedChecksum) { - throw new Error(`Checksum for ${filename} not found`); - } + const expectedChecksum = await this.findChecksum( + `${baseUrl}hashes.sha256`, + filename, + ); const file = await this.http.download({ url: `${baseUrl}${filename}`, @@ -108,14 +101,3 @@ export class PowershellInstallService extends BaseInstallService { await this._spawn('pwsh', ['-version']); } } - -/** - * Decodes the release's checksum file, which is UTF-16LE with a BOM, falling - * back to UTF-8 without one. - */ -function readChecksums(buf: Buffer): string { - if (buf[0] === 0xff && buf[1] === 0xfe) { - return buf.toString('utf16le'); - } - return buf.toString('utf8'); -}