diff --git a/.gitignore b/.gitignore index 57c1939..d6afd29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ +out node_modules/ dist/ *.vsix -out/ \ No newline at end of file +out/result diff --git a/README.md b/README.md index 88f376b..5562e5b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ GitHub 仓库:[https://github.com/Zwhy2025/open-dev-container ](https://githu - 主机上可用的 Docker CLI。 - 主机上可用的 `ssh` 和 `ssh-keygen`。 - 容器允许执行 `docker exec -u 0`。 -- 如果容器里没有 `sshd`,需要 `apt-get`、`apk`、`dnf`、`yum` 或 `microdnf` 之一。 +- 如果容器里没有 `sshd`,需要 `apt-get`、`apk`、`dnf`、`yum`、`microdnf` 或 `pacman` 之一。 ## 使用方法 @@ -55,3 +55,38 @@ Open Dev Container: Attach to Running Container - `openDevContainer.sshConfigPath`:SSH 配置路径;留空表示 `~/.ssh/config`。 最近连接会保存在扩展全局存储中,编辑器重启后仍然可用。 + +## Nix / NixOS + +This repo ships a flake that builds the extension as a nixpkgs-style VS Code +extension derivation (`share/vscode/extensions/Zwhy2025.open-dev-container`). + +```nix +# flake.nix of your NixOS / home-manager config +inputs.open-dev-container.url = "github:int3hh/open-dev-container"; + +# home-manager (VS Code or VSCodium – set `package` accordingly) +programs.vscode = { + enable = true; + package = pkgs.vscodium; # omit for VS Code + profiles.default.extensions = [ + inputs.open-dev-container.packages.${pkgs.system}.default + pkgs.vscode-extensions.jeanp413.open-remote-ssh # Remote SSH for VSCodium + ]; +}; + +# or plain nixpkgs +environment.systemPackages = [ + (pkgs.vscode-with-extensions.override { + vscode = pkgs.vscodium; # omit for VS Code + vscodeExtensions = [ inputs.open-dev-container.packages.${pkgs.system}.default ]; + }) +]; +``` + +VSCodium note: Microsoft's `ms-vscode-remote.remote-ssh` is not available for +VSCodium; use `jeanp413.open-remote-ssh` instead, which provides the same +`ssh-remote` authority this extension opens. + +An overlay is also exported (`inputs.open-dev-container.overlays.default`) +which adds `pkgs.open-dev-container`. Build locally with `nix build`. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..de06ac7 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786862985, + "narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5564125 --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + description = "Open Dev Container – VS Code extension: attach to running Docker/Podman containers via Remote SSH"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + let + overlay = final: prev: { + open-dev-container = final.callPackage ./nix/package.nix { }; + }; + in + { + overlays.default = overlay; + } + // flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; overlays = [ overlay ]; }; + in + { + packages = { + default = pkgs.open-dev-container; + open-dev-container = pkgs.open-dev-container; + }; + + devShells.default = pkgs.mkShell { + packages = [ pkgs.nodejs pkgs.typescript ]; + }; + }); +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..7089de4 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,66 @@ +# VS Code extension derivation for Open Dev Container. +# +# Compiles src/ with tsc (via buildNpmPackage) and wraps the result with +# vscode-utils.buildVscodeExtension so it can be used in +# programs.vscode.profiles.default.extensions (home-manager) +# vscode-with-extensions (nixpkgs) +{ lib +, buildNpmPackage +, importNpmLock +, vscode-utils +}: +let + manifest = lib.importJSON ../package.json; + inherit (manifest) name version publisher; + + compiled = buildNpmPackage { + pname = "${name}-compiled"; + inherit version; + + src = lib.cleanSourceWith { + src = ../.; + filter = path: type: + let base = baseNameOf path; in + !(lib.elem base [ "node_modules" "out" "dist" ".git" "result" "nix" "flake.nix" "flake.lock" ]); + }; + + # No hash needed: dependencies are fetched straight from package-lock.json. + npmDeps = importNpmLock { npmRoot = ../.; }; + npmConfigHook = importNpmLock.npmConfigHook; + + # sharp (native, dev-only) is not needed to compile the extension. + npmFlags = [ "--ignore-scripts" ]; + dontNpmRebuild = true; + + npmBuildScript = "compile"; + + # Only ship what a .vsix would contain (see .vscodeignore). + installPhase = '' + runHook preInstall + # buildVscodeExtension expects the .vsix layout: everything under extension/ + mkdir -p $out/extension + cp -r package.json out resources LICENSE README.md $out/extension/ + runHook postInstall + ''; + }; +in +vscode-utils.buildVscodeExtension { + pname = name; + inherit version; + # Hand over the sub-directory, not the whole output: stdenv copies a + # directory src into the sandbox as ./extension (buildVscodeExtension's + # default sourceRoot) and chmods the copy. Pointing sourceRoot at the store + # path directly fails on a real NixOS store (read-only). + src = "${compiled}/extension"; + + vscodeExtPublisher = publisher; + vscodeExtName = name; + vscodeExtUniqueId = "${publisher}.${name}"; + + meta = with lib; { + description = manifest.description; + homepage = "https://github.com/int3hh/open-dev-container"; + license = licenses.mit; + platforms = platforms.all; + }; +} diff --git a/package.json b/package.json index 1c28cf8..7b166a1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-dev-container", - "displayName": "Open Dev Container", - "description": "Open running Docker containers as development workspaces via Remote SSH. Supports VS Code-compatible editors such as Trae CN.", + "displayName": "Open Dev Container (podman fork)", + "description": "Open running Docker containers as development workspaces via Remote SSH. Supports VSCodium and jeanp413 SSH extension", "version": "0.0.3", "publisher": "Zwhy2025", "license": "MIT", @@ -122,8 +122,8 @@ "properties": { "openDevContainer.dockerPath": { "type": "string", - "default": "docker", - "description": "Docker CLI executable used by this extension." + "default": "podman", + "description": "Container CLI executable used by this extension (podman or docker)." }, "openDevContainer.remoteUser": { "type": "string", diff --git a/src/commandRunner.ts b/src/commandRunner.ts index 787a138..9d147f3 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -2,6 +2,10 @@ import { execFile } from 'node:child_process'; import { OpenDevContainerError, formatErrorDetail } from './errors'; import type { ExecResult } from './types'; +function isContainerCli(file: string): boolean { + return /(^|[\\/])(docker|podman)(\.exe)?$/i.test(file); +} + export async function execFileAsync( file: string, args: string[], @@ -29,10 +33,10 @@ export function normalizeCommandError( const combined = [stderr.trim(), stdout.trim(), error.message].filter(Boolean).join('\n').trim(); if (error.code === 'ENOENT') { - if (file === 'docker') { + if (isContainerCli(file)) { return new OpenDevContainerError( 'DOCKER_CLI_MISSING', - 'Docker CLI was not found. Configure `openDevContainer.dockerPath` or install Docker.', + `${file} was not found. Configure \`openDevContainer.dockerPath\` or install Podman/Docker.`, combined ); } @@ -46,20 +50,20 @@ export function normalizeCommandError( } } - if (file === 'docker') { - if (/cannot connect to the Docker daemon|permission denied while trying to connect to the Docker daemon|is the docker daemon running|error during connect/i.test(combined)) { + if (isContainerCli(file)) { + if (/cannot connect to the Docker daemon|permission denied while trying to connect to the Docker daemon|is the docker daemon running|error during connect|cannot connect to podman|unable to connect to podman socket|podman.sock/i.test(combined)) { return new OpenDevContainerError( 'DOCKER_DAEMON_UNAVAILABLE', - 'Docker daemon is not reachable or the current user cannot access it.', + 'The container engine is not reachable or the current user cannot access it.', combined, typeof error.code === 'number' ? error.code : undefined ); } - if (/permission denied|operation not permitted/i.test(combined) && /docker/i.test(combined)) { + if (/permission denied|operation not permitted/i.test(combined) && /docker|podman/i.test(combined)) { return new OpenDevContainerError( 'DOCKER_PERMISSION_DENIED', - 'Docker denied the requested operation. Check socket permissions, rootless mode, or container policy.', + 'The container engine denied the requested operation. Check socket permissions, rootless mode, or container policy.', combined, typeof error.code === 'number' ? error.code : undefined ); diff --git a/src/containerProvisioner.ts b/src/containerProvisioner.ts index 629b37b..2a52fff 100644 --- a/src/containerProvisioner.ts +++ b/src/containerProvisioner.ts @@ -90,6 +90,8 @@ if ! command -v sshd >/dev/null 2>&1 && [ ! -x /usr/sbin/sshd ]; then yum install -y openssh-server elif command -v microdnf >/dev/null 2>&1; then microdnf install -y openssh-server + elif command -v pacman >/dev/null 2>&1; then + pacman -Sy --noconfirm openssh else echo "OPEN_DEV_CONTAINER_ERROR=NO_PACKAGE_MANAGER" >&2 echo "openssh-server is missing and no supported package manager was found." >&2 @@ -131,21 +133,28 @@ ssh-keygen -A >/dev/null 2>&1 || true cat > ${shellQuote(CONTAINER_FORCE_COMMAND_SCRIPT)} <<'EOF' #!/bin/sh -set -eu - +# Run the requested command (or a login shell) and exit with its status. +# The channel must close when the command ends: Remote SSH clients such as +# open-remote-ssh wait for exec() to close before continuing, so keeping the +# session alive here would hang the connection forever. +# Commands run exactly like stock sshd does it: "$SHELL -c", *not* a login +# shell. Login startup files (/etc/profile, ~/.profile) often print banners +# or exec an interactive shell, which breaks Remote SSH's install script. if [ -n "\${SSH_ORIGINAL_COMMAND:-}" ]; then - sh -lc "$SSH_ORIGINAL_COMMAND" || true -else - if command -v bash >/dev/null 2>&1; then - bash -l || true - else - /bin/sh || true - fi + CMD=$SSH_ORIGINAL_COMMAND + # open-remote-ssh installs the VSCodium server with "... | bash -l". A *login* + # shell sources the container's profile/rc files; in hand-built images those + # frequently read stdin or exit for non-interactive shells, which swallows the + # piped install script -> the client fails with "Failed parsing install script + # output". The install needs no login environment, so strip the login shell. + case "$CMD" in + *"| bash -l") CMD="\${CMD%| bash -l}| bash" ;; + *"| bash --login") CMD="\${CMD%| bash --login}| bash" ;; + esac + exec "\${SHELL:-/bin/sh}" -c "$CMD" fi - -while :; do - sleep 3600 -done +# Interactive session: login shell. +exec "\${SHELL:-/bin/sh}" -l EOF chmod 755 ${shellQuote(CONTAINER_FORCE_COMMAND_SCRIPT)} diff --git a/src/dockerClient.ts b/src/dockerClient.ts index f64d244..fbb380d 100644 --- a/src/dockerClient.ts +++ b/src/dockerClient.ts @@ -21,13 +21,41 @@ export function parseDockerContainerList(stdout: string): DockerContainer[] { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) as DockerContainer; - } catch { - throw new OpenDevContainerError('DOCKER_OUTPUT_PARSE_FAILED', 'Docker returned container output that could not be parsed.', line); - } - }); + .map((line) => normalizeContainerRecord(parseContainerLine(line), line)); +} + +function parseContainerLine(line: string): unknown { + try { + return JSON.parse(line); + } catch { + throw new OpenDevContainerError('DOCKER_OUTPUT_PARSE_FAILED', 'Docker returned container output that could not be parsed.', line); + } +} + +// Podman's `{{json .}}` marshals the raw ListContainer struct instead of the accessor methods +// Docker exposes: the id is tagged `Id`, `Names` is an array, and `Status` is left empty. +export function normalizeContainerRecord(raw: unknown, line?: string): DockerContainer { + const record = (typeof raw === 'object' && raw !== null ? raw : {}) as Record; + const names = record.Names; + const state = typeof record.State === 'string' ? record.State : ''; + const status = typeof record.Status === 'string' ? record.Status : ''; + const id = typeof record.ID === 'string' ? record.ID : typeof record.Id === 'string' ? record.Id : ''; + + if (!id) { + throw new OpenDevContainerError( + 'DOCKER_OUTPUT_PARSE_FAILED', + 'Docker returned a container entry without an ID.', + line ?? JSON.stringify(raw) + ); + } + + return { + ID: id, + Image: typeof record.Image === 'string' ? record.Image : '', + Names: Array.isArray(names) ? names.join(',') : typeof names === 'string' ? names : '', + State: state, + Status: status || state + }; } export function parseDockerInspect(stdout: string, containerId: string): DockerInspect { diff --git a/src/extension.ts b/src/extension.ts index ca77797..65d6d71 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -81,7 +81,7 @@ class RecentConnectionTreeItem extends vscode.TreeItem { `Workspace: ${connection.workspaceFolder}`, `Remote user: ${connection.remoteUser}`, `SSH config: ${connection.sshConfigPath}`, - `Docker CLI: ${connection.dockerPath}`, + `Container CLI: ${connection.dockerPath}`, `Working dir: ${connection.workingDir || '/'}`, `Mounts: ${connection.mountSummary || 'none'}`, `Last attached: ${formatTimestamp(connection.lastAttachedAt)}`, @@ -449,7 +449,7 @@ class OpenDevContainerService { private getCurrentSettings(): ConnectionSettings { const config = vscode.workspace.getConfiguration('openDevContainer'); return { - dockerPath: config.get('dockerPath') || 'docker', + dockerPath: config.get('dockerPath') || 'podman', remoteUser: ensureSafeRemoteUser(config.get('remoteUser') || 'root'), workspaceFolder: config.get('workspaceFolder') || '', installSshd: config.get('installSshd') ?? true, diff --git a/test/core.test.js b/test/core.test.js index 3821a1c..c385ce4 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -29,11 +29,26 @@ test('parseDockerContainerList parses docker JSON lines', () => { assert.equal(containers[1].Image, 'redis'); }); +test('parseDockerContainerList normalizes podman container output', () => { + const containers = parseDockerContainerList( + '{"Id":"8c5872883dfd","Image":"docker.io/library/archlinux:latest","Names":["archi"],"State":"running","Status":""}' + ); + + assert.equal(containers.length, 1); + assert.equal(containers[0].ID, '8c5872883dfd'); + assert.equal(containers[0].Names, 'archi'); + assert.equal(containers[0].Status, 'running'); +}); + test('parseDockerContainerList reports invalid docker output', () => { assert.throws( () => parseDockerContainerList('not json'), (error) => error instanceof OpenDevContainerError && error.code === 'DOCKER_OUTPUT_PARSE_FAILED' ); + assert.throws( + () => parseDockerContainerList('{"Image":"node:22","Names":["app"],"State":"running"}'), + (error) => error instanceof OpenDevContainerError && error.code === 'DOCKER_OUTPUT_PARSE_FAILED' + ); }); test('parseDockerInspect handles valid, empty, and invalid inspect output', () => { @@ -148,6 +163,7 @@ test('RecentConnectionStore filters invalid records, sorts, deduplicates, and re test('buildPrepareSshdScript includes install and no-install failure paths', () => { assert.match(buildPrepareSshdScript('root', 'ssh-ed25519 AAA test', true), /apt-get install -y openssh-server/); + assert.match(buildPrepareSshdScript('root', 'ssh-ed25519 AAA test', true), /pacman -Sy --noconfirm openssh/); assert.match(buildPrepareSshdScript('root', 'ssh-ed25519 AAA test', false), /OPEN_DEV_CONTAINER_ERROR=MISSING_SSHD/); }); @@ -183,3 +199,16 @@ class MemoryStorage { this.value = value; } } + +test('buildPrepareSshdScript force command exits with the command instead of lingering', () => { + const script = buildPrepareSshdScript('root', 'ssh-ed25519 AAAA test', false); + const forceCommand = script.match(/<<'EOF'\n([\s\S]*?)\nEOF/)[1]; + // open-remote-ssh resolves exec() only when the channel closes; a lingering + // session (the old `while :; do sleep 3600; done`) hangs the connection. + assert.doesNotMatch(forceCommand, /sleep 3600/); + assert.match(forceCommand, /exec "\$\{SHELL:-\/bin\/sh\}" -c "\$CMD"/); + assert.doesNotMatch(forceCommand, /-lc/); + // Strips the login shell from open-remote-ssh's "... | bash -l" server install + // so hostile container profile/rc files cannot swallow the piped script. + assert.match(forceCommand, /\| bash -l"\)\s+CMD="\$\{CMD%\| bash -l\}\| bash"/); +});