# Setting Up `unpack` in PowerShell `unpack` recursively extracts archives into a folder of their own, then deletes the source archive and its sibling volumes once extraction succeeds. This is the Windows port of [`unpack.sh`](https://github.com/NullAngst/shell_functions#functions) and follows it as closely as PowerShell allows: same formats, same options, same naming and cleanup rules, same recursion limit. The one deliberate difference is that every format goes through 7-Zip. [Differences from unpack.sh](#7-differences-from-unpacksh) lists everything that behaves differently. The script targets Windows PowerShell 5.1 (built into Windows 10/11) and PowerShell 7. ## 1. Install 7-Zip ```powershell winget install --id 7zip.7zip -e ``` `-e` forces an exact match on the package ID instead of a fuzzy search. You do not need to add 7-Zip to your PATH. The installer does not do it, so `unpack` checks PATH first and then the default install folders (`C:\Program Files\7-Zip` and `C:\Program Files (x86)\7-Zip`). Format notes: * **zstd** (`.zst`, `.tar.zst`, `.tzst`) needs 7-Zip 24.01 or newer. winget installs the current release, but an older copy already on the machine will fail on these files. * **RAR** extraction covers RAR4 and RAR5. The exception is archives made by WinRAR 7 with a dictionary larger than 4 GB: 7-Zip's command line caps RAR unpacking memory at 4 GB unless given `-smemx`, which `unpack` does not pass, so those fail and the source is kept. Optional: if you also want to type `7z` yourself at the prompt, this adds the install folder to your user PATH. It compares whole PATH entries, so an existing entry like `C:\Program Files\7-Zip-old` is not mistaken for a match. ```powershell $sevenZipDir = @("$env:ProgramFiles\7-Zip", "${env:ProgramFiles(x86)}\7-Zip") | Where-Object { Test-Path -LiteralPath (Join-Path $_ '7z.exe') } | Select-Object -First 1 if (-not $sevenZipDir) { Write-Error "Could not find 7z.exe in the standard installation folders." } else { $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $entries = @($userPath -split ';' | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }) if ($entries -contains $sevenZipDir.TrimEnd('\')) { Write-Output "7-Zip is already in PATH." } else { [Environment]::SetEnvironmentVariable('Path', (($entries + $sevenZipDir) -join ';'), 'User') $env:Path += ";$sevenZipDir" Write-Output "Added $sevenZipDir to PATH." } } ``` ## 2. Allow Local Scripts to Run Your profile is a script, and so is `unpack.ps1`. Windows PowerShell 5.1 ships with the execution policy set to `Restricted` on Windows 10 and 11, which blocks both, and the profile fails to load with "running scripts is disabled on this system". Check the current setting: ```powershell Get-ExecutionPolicy ``` If it prints `Restricted` or `AllSigned`, allow locally created scripts for your account only (no admin rights needed): ```powershell Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned ``` `RemoteSigned` still blocks unsigned scripts downloaded from the internet. If you download `unpack.ps1` with a browser instead of pasting it in step 3, clear the download mark once: ```powershell Unblock-File -LiteralPath "$HOME\Scripts\unpack.ps1" ``` ## 3. Save the Script Create a folder for it and open a new file in Notepad: ```powershell New-Item -ItemType Directory -Force -Path "$HOME\Scripts" | Out-Null notepad "$HOME\Scripts\unpack.ps1" ``` Notepad asks whether to create the file. Choose **Yes**, paste the entire script from [section 5](#5-the-script), save, and close Notepad. The script is plain ASCII on purpose. Windows PowerShell 5.1 reads files without a byte order mark using the legacy ANSI code page, so a pasted smart quote or dash would be misread. If you edit it, keep it ASCII. ## 4. Load It From Your Profile The profile runs every time you start a PowerShell session. This creates it if needed and adds one line that dot-sources `unpack.ps1`, skipping the append if the line is already there: ```powershell if (-not (Test-Path -LiteralPath $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null } $line = '. "$HOME\Scripts\unpack.ps1"' if (-not (Select-String -LiteralPath $PROFILE -SimpleMatch $line -Quiet)) { Add-Content -LiteralPath $PROFILE -Value $line } ``` The single quotes are intentional: `$HOME` is expanded each time the profile loads, not when the line is written. Windows PowerShell 5.1 and PowerShell 7 keep separate profiles (`Documents\WindowsPowerShell` and `Documents\PowerShell`). If you use both, run this step once in each. They share the same `unpack.ps1`. Load it into the current session without restarting: ```powershell . "$HOME\Scripts\unpack.ps1" ``` If you would rather keep everything in the profile itself, pasting the script body straight into `$PROFILE` also works. The profile is dot-sourced, so the self-run block at the bottom of the script stays inactive there. ## 5. The Script Save this as `$HOME\Scripts\unpack.ps1`. ```powershell # unpack - recursively extract archives into a directory of their own, then # delete the source archive (and its sibling volumes) on success. # # PowerShell port of unpack.sh. Every format goes through 7-Zip. # # Formats: tar, tar.gz/tgz, tar.bz2/tbz2/tbz, tar.xz/txz, tar.zst/tzst, zip, # rar, 7z, bare gz/bz2/xz/zst, and multi-volume sets (.partN.rar, .rNN/.sNN, # .zNN, .zip.NNN, .7z.NNN, and generic .NNN splits such as name.tar.001). # # Usage: unpack [-k] [-n] [-v] [-d N] [more...] # -k (-Keep) keep source archives instead of deleting them # -n (-DryRun) dry run: report what would happen, change nothing # -v (-Verbose) verbose: show extractor output # -d N (-MaxDepth) maximum nesting depth for archives inside archives (default 8) # -h (-Help) this help # # Status is left in $LASTEXITCODE: 0 if every target succeeded, 1 otherwise, # 2 on usage errors. # # Requires 7-Zip 24.01 or newer for zstd; everything else works on any # current 7-Zip. 7z.exe is found on PATH or in the default install folders. # # Dot-source this file to define the function (. .\unpack.ps1), or run it # directly to unpack in one go (.\unpack.ps1 archive.zip). # # Written for Windows PowerShell 5.1 and PowerShell 7. Keep this file ASCII-only: # Windows PowerShell 5.1 reads BOM-less files in the legacy ANSI code page. function unpack { [CmdletBinding(PositionalBinding = $false)] param( [Alias('k')][switch]$Keep, [Alias('n')][switch]$DryRun, [Alias('d')][ValidateRange(0, 2147483647)][int]$MaxDepth = 8, [Alias('h')][switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$Targets ) # PowerShell 7.4+ can turn a nonzero native exit code into an error that # stops the function. Exit codes are checked explicitly below instead. $PSNativeCommandUseErrorActionPreference = $false # Read by the nested helpers through PowerShell's dynamic scoping, the # same way the bash helpers read unpack's locals. $_unpack_keep = [bool]$Keep $_unpack_dry = [bool]$DryRun $_unpack_verbose = [bool]$PSBoundParameters['Verbose'] $_unpack_maxdepth = $MaxDepth $_unpack_sep = [System.IO.Path]::DirectorySeparatorChar function _unpack_usage { 'Usage: unpack [-k] [-n] [-v] [-d N] [more...]' ' -k (-Keep) keep source archives instead of deleting them' ' -n (-DryRun) dry run: report what would happen, change nothing' ' -v (-Verbose) verbose: show extractor output' ' -d N (-MaxDepth) maximum nesting depth for archives inside archives (default 8)' ' -h (-Help) show this help' } function _unpack_warn([string]$msg) { Write-Warning "unpack: $msg" } function _unpack_info([string]$msg) { Write-Host $msg } # First application found under any of the given names on PATH, then in # the given fallback locations. 7-Zip's installer does not touch PATH, # so the default install folders are checked too. function _unpack_find_app([string[]]$names, [string[]]$fallbacks) { foreach ($n in $names) { $c = Get-Command $n -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($c) { return $c.Path } } foreach ($p in $fallbacks) { if ($p -and (Test-Path -LiteralPath $p -PathType Leaf)) { return $p } } return $null } # First available 7-Zip binary. 7za cannot read rar, which is handled below. function _unpack_7zbin { $fallbacks = @() foreach ($root in @($env:ProgramFiles, $env:ProgramW6432, ${env:ProgramFiles(x86)})) { if ($root) { $fallbacks += [System.IO.Path]::Combine($root, '7-Zip', '7z.exe') } } return (_unpack_find_app @('7zz', '7z', '7za') $fallbacks) } # Run 7-Zip with stdin closed (so it can never block on a password prompt) # and stdout suppressed unless -v was given. stderr always passes through # so real failures stay visible. function _unpack_run7z([string]$exe, [string[]]$argv) { if ($_unpack_verbose) { $null | & $exe @argv | Out-Host } else { $null | & $exe @argv | Out-Null } return ($LASTEXITCODE -eq 0) } # Classify a lowercased basename. Order matters: longer suffixes first. function _unpack_kind([string]$lb) { switch -Wildcard ($lb) { '*.tar.gz' { return 'tar_gz' } '*.tgz' { return 'tar_gz' } '*.tar.bz2' { return 'tar_bz2' } '*.tbz2' { return 'tar_bz2' } '*.tbz' { return 'tar_bz2' } '*.tar.xz' { return 'tar_xz' } '*.txz' { return 'tar_xz' } '*.tar.zst' { return 'tar_zst' } '*.tzst' { return 'tar_zst' } '*.tar' { return 'tar' } '*.rar' { return 'rar' } '*.zip' { return 'zip' } '*.zip.001' { return 'zip' } '*.7z' { return '7z' } '*.7z.001' { return '7z' } '*.gz' { return 'gz' } '*.bz2' { return 'bz2' } '*.xz' { return 'xz' } '*.zst' { return 'zst' } '*.[0-9][0-9][0-9]' { return 'split' } } return $null } # True for a non-first volume of a multi-volume set. Those are pulled in by # the extractor when the first volume is opened, so they are never a target. function _unpack_is_continuation([string]$lb) { if ($lb -like '*.part[0-9]*.rar') { return ($lb -notmatch '\.part0{0,3}1\.rar$') } if ($lb -match '\.[rs][0-9][0-9]$' -or $lb -match '\.z[0-9][0-9]+$') { return $true } if ($lb -match '\.[0-9][0-9][0-9]$' -and $lb -notmatch '\.001$') { return $true } return $false } # Equivalent of bash ${s%.*}: drop the last dot and everything after it. function _unpack_drop_ext([string]$s) { $i = $s.LastIndexOf('.') if ($i -ge 0) { return $s.Substring(0, $i) } return $s } # Remove one known archive suffix from a basename, preserving its case. function _unpack_strip_suffix([string]$b) { $lb = $b.ToLowerInvariant() if ($lb -like '*.part[0-9]*.rar') { return (_unpack_drop_ext (_unpack_drop_ext $b)) } foreach ($suf in @('.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tgz', '.tbz2', '.tbz', '.txz', '.tzst', '.tar', '.zip.001', '.zip', '.7z.001', '.7z', '.rar', '.gz', '.bz2', '.xz', '.zst')) { if ($lb.EndsWith($suf, [System.StringComparison]::Ordinal)) { return $b.Substring(0, $b.Length - $suf.Length) } } if ($lb -like '*.[0-9][0-9][0-9]') { return $b.Substring(0, $b.Length - 4) } return $b } # A path that does not exist yet, so extraction never merges into or # overwrites something that was already there. function _unpack_unique_path([string]$p) { if (-not (Test-Path -LiteralPath $p)) { return $p } $i = 1 while (Test-Path -LiteralPath "$($p)_$i") { $i++ } return "$($p)_$i" } function _unpack_is_empty_dir([string]$d) { return ($null -eq (Get-ChildItem -LiteralPath $d -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) } # Delete the volumes belonging to one archive set. The prefix is compared # literally (case-insensitively) rather than being passed to a wildcard # path, so a name containing *, ? or [ ] cannot reach unrelated files. function _unpack_delete_set([string]$dir, [string]$prefix, [string]$mode) { $plower = $prefix.ToLowerInvariant() switch ($mode) { 'rar_old' { $pattern = '^\.(rar|r[0-9][0-9]|s[0-9][0-9])$' } 'rar_part' { $pattern = '^\.part[0-9]+\.rar$' } 'zip' { $pattern = '^(\.zip|\.z[0-9][0-9]+|\.zip\.[0-9][0-9][0-9])$' } 'sevenz' { $pattern = '^(\.7z|\.7z\.[0-9][0-9][0-9])$' } 'split' { $pattern = '^\.[0-9][0-9][0-9]$' } default { return } } foreach ($f in @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue)) { $bl = $f.Name.ToLowerInvariant() if (-not $bl.StartsWith($plower, [System.StringComparison]::Ordinal)) { continue } if ($bl.Substring($plower.Length) -notmatch $pattern) { continue } Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue } } function _unpack_missing([string]$tool, [string]$file) { _unpack_warn "cannot extract '$file': $tool is not installed" return $false } # A fresh scratch directory under $parent for 7-Zip's intermediate output. function _unpack_new_stage([string]$parent) { $stage = _unpack_unique_path ([System.IO.Path]::Combine($parent, '.unpack-stage')) [void][System.IO.Directory]::CreateDirectory($stage) return $stage } # The single file a one-layer decompression left in $stage, or $null. function _unpack_single_file([string]$stage) { $files = @(Get-ChildItem -LiteralPath $stage -File -Force -ErrorAction SilentlyContinue) if ($files.Count -ne 1) { return $null } return $files[0].FullName } # $file archive, $dest destination directory, $kind, $stem (for bare # compressors, the name to write inside the destination). function _unpack_extract([string]$file, [string]$dest, [string]$kind, [string]$stem) { $z = _unpack_7zbin if (-not $z) { return (_unpack_missing '7-Zip (7z.exe)' $file) } if ($kind -eq 'rar' -and [System.IO.Path]::GetFileNameWithoutExtension($z) -eq '7za') { return (_unpack_missing 'a 7-Zip build that reads rar (7za cannot)' $file) } switch ($kind) { { $_ -in 'tar', 'zip', '7z', 'split', 'rar' } { return (_unpack_run7z $z @('x', '-aou', '-y', '-bd', "-o$dest", $file)) } { $_ -in 'tar_gz', 'tar_bz2', 'tar_xz', 'tar_zst' } { # 7-Zip removes one layer per pass, so the compressed stream is # expanded to a .tar inside $dest first, then unpacked from there. $stage = _unpack_new_stage $dest try { if (-not (_unpack_run7z $z @('x', '-aou', '-y', '-bd', "-o$stage", $file))) { return $false } $inner = _unpack_single_file $stage if (-not $inner) { _unpack_warn "'$file' did not decompress to a single tar stream" return $false } return (_unpack_run7z $z @('x', '-ttar', '-aou', '-y', '-bd', "-o$dest", $inner)) } finally { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue } } { $_ -in 'gz', 'bz2', 'xz', 'zst' } { # 7-Zip picks the output name itself (gzip can carry one in its # header), so decompress into a stage and move the result to # the name chosen by the caller. $stage = _unpack_new_stage $dest try { if (-not (_unpack_run7z $z @('x', '-aou', '-y', '-bd', "-o$stage", $file))) { return $false } $inner = _unpack_single_file $stage if (-not $inner) { _unpack_warn "'$file' did not decompress to a single file" return $false } [System.IO.File]::Move($inner, [System.IO.Path]::Combine($dest, $stem)) return $true } catch { _unpack_warn "could not move decompressed output of '$file': $($_.Exception.Message)" return $false } finally { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue } } default { _unpack_warn "internal error: unhandled kind '$kind'" return $false } } } # Scan one directory level for archives worth unpacking. function _unpack_dir([string]$dir, [int]$depth) { $ok = $true foreach ($f in @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue)) { $bl = $f.Name.ToLowerInvariant() if (-not (_unpack_kind $bl)) { continue } if (_unpack_is_continuation $bl) { continue } if (-not (_unpack_one $f.FullName $depth)) { $ok = $false } } return $ok } function _unpack_one([string]$target, [int]$depth) { if ([string]::IsNullOrEmpty($target)) { _unpack_warn "'' is not a regular file or directory" return $false } if ($depth -gt $_unpack_maxdepth) { _unpack_warn "depth limit ($_unpack_maxdepth) reached, leaving '$target' packed" return $false } if (Test-Path -LiteralPath $target -PathType Container) { return (_unpack_dir $target $depth) } if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { _unpack_warn "'$target' is not a regular file or directory" return $false } $item = Get-Item -LiteralPath $target -Force $file = $item.FullName $dir = $item.DirectoryName $base = $item.Name $lb = $base.ToLowerInvariant() if (_unpack_is_continuation $lb) { _unpack_info "skipping '$base': part of a multi-volume set, unpack the first volume instead" return $true } $kind = _unpack_kind $lb if (-not $kind) { _unpack_warn "unsupported archive format: '$file'" return $false } $stem = _unpack_strip_suffix $base if (-not $stem) { $stem = "$base.out" } # Bare single-file compressors produce one file, not a tree, so they are # decompressed alongside the archive instead of into a new directory. if ($kind -in 'gz', 'bz2', 'xz', 'zst') { $out = _unpack_unique_path ([System.IO.Path]::Combine($dir, $stem)) $outName = [System.IO.Path]::GetFileName($out) if ($_unpack_dry) { _unpack_info "would decompress '$file' -> '$out'" return $true } if (_unpack_extract $file $dir $kind $outName) { _unpack_info "decompressed '$base' -> '$outName'" if (-not $_unpack_keep) { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } if (_unpack_kind $outName.ToLowerInvariant()) { return (_unpack_one $out ($depth + 1)) } return $true } _unpack_warn "decompression failed for '$file', source kept" Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue return $false } # Volume set bookkeeping. $volPrefix is matched literally, not as a wildcard. $volPrefix = '' $volMode = '' switch ($kind) { 'split' { $volPrefix = $stem $volMode = 'split' $inner = $stem $stem = _unpack_strip_suffix $inner if (-not $stem) { $stem = $inner } } 'zip' { $volPrefix = $stem; $volMode = 'zip' } '7z' { $volPrefix = $stem; $volMode = 'sevenz' } 'rar' { $volPrefix = $stem if ($lb -like '*.part[0-9]*.rar') { $volMode = 'rar_part' } else { $volMode = 'rar_old' } } } $dest = _unpack_unique_path ([System.IO.Path]::Combine($dir, $stem)) $destShown = "$dest$_unpack_sep" if ($_unpack_dry) { _unpack_info "would unpack '$file' -> '$destShown'" return $true } try { [void][System.IO.Directory]::CreateDirectory($dest) } catch { _unpack_warn "could not create '$dest'" return $false } if (_unpack_extract $file $dest $kind $stem) { _unpack_info "unpacked '$base' -> '$destShown'" if (-not $_unpack_keep) { if ($volMode) { _unpack_delete_set $dir $volPrefix $volMode } else { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } } if (_unpack_is_empty_dir $dest) { Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue _unpack_warn "'$base' contained nothing" return $true } return (_unpack_dir $dest ($depth + 1)) } _unpack_warn "extraction failed for '$file', sources kept" # $dest did not exist before this call, so removing it is safe. It only # survives if the extractor left partial output behind. if (_unpack_is_empty_dir $dest) { Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue } else { _unpack_warn "partial output left in '$destShown'" } return $false } # ---- entry point ---- if ($Help) { _unpack_usage | Write-Host $global:LASTEXITCODE = 0 return } # Anything dash-prefixed that PowerShell did not bind (-x, or bundled # flags such as -kn) lands in $Targets. Treat it as an unknown option # unless a file by that name really exists. foreach ($t in @($Targets | Where-Object { $null -ne $_ })) { if ($t -like '-*' -and -not (Test-Path -LiteralPath $t)) { _unpack_warn "unknown option $t (flags cannot be bundled; use -k -n, not -kn)" $global:LASTEXITCODE = 2 return } } if ($null -eq $Targets -or $Targets.Count -eq 0) { _unpack_usage | Write-Host $global:LASTEXITCODE = 2 return } # PowerShell does not expand wildcards for functions the way bash does # for every command, so expand them here. An exact name always wins, so # files with [ ] in their names still work. $resolved = @() foreach ($t in $Targets) { if ([string]::IsNullOrEmpty($t)) { $resolved += '' } elseif (Test-Path -LiteralPath $t) { $resolved += (Get-Item -LiteralPath $t -Force).FullName } elseif ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($t)) { $found = @(Get-Item -Path $t -Force -ErrorAction SilentlyContinue) if ($found.Count -eq 0) { $resolved += $t } else { $resolved += @($found | ForEach-Object { $_.FullName }) } } else { $resolved += $t } } $rc = 0 foreach ($t in $resolved) { if (-not (_unpack_one $t 0)) { $rc = 1 } } $global:LASTEXITCODE = $rc } # Run directly (.\unpack.ps1 archive.zip, or powershell -File unpack.ps1 ...) # instead of dot-sourced: unpack the arguments and exit with the status. if ($MyInvocation.InvocationName -ne '.') { unpack @args exit $LASTEXITCODE } ``` ## 6. Usage Once loaded, `unpack` works like any other command. | Option | Long form | Effect | |---|---|---| | `-k` | `-Keep` | Keep source archives instead of deleting them | | `-n` | `-DryRun` | Report what would happen, change nothing | | `-v` | `-Verbose` | Show 7-Zip's output | | `-d N` | `-MaxDepth N` | Maximum nesting depth for archives inside archives (default 8) | | `-h` | `-Help` | Show usage | Examples: * **Single archive:** `unpack archive.tar.gz` * **Several at once:** `unpack file1.zip file2.part01.rar` * **Every archive in a folder:** `unpack C:\Path\To\Archives` * **Wildcards:** `unpack *.zip` * **Preview first:** `unpack -n C:\Downloads` * **Keep the originals:** `unpack -k backup.7z` * **Shallow nesting only:** `unpack -d 1 bundle.zip` * **Check the result in a script:** `unpack stuff.zip; if ($LASTEXITCODE -ne 0) { ... }` What it does with each target: * Archives are extracted into a new folder named after the archive minus its extension (`photos.tar.gz` becomes `photos\`). If that name is taken, it uses `photos_1`, `photos_2` and so on, so nothing already on disk is merged into or overwritten. * Bare compressed files (`notes.txt.gz`, `.bz2`, `.xz`, `.zst`) are decompressed next to the original under the same unique-name rule. If the result is itself an archive, it is unpacked in turn. * Multi-volume sets (`.part1.rar`, `.rar` + `.r00`, `.zip` + `.z01`, `.zip.001`, `.7z.001`, `name.tar.001`) are extracted from the first volume. Later volumes are skipped when passed directly or found in a folder, and the whole set is deleted after a successful extraction. * A directory target is scanned one level deep, hidden files included. Each new output folder is scanned the same way, so archives inside archives are unpacked until `-MaxDepth` is reached. * On failure the source files are kept. The empty output folder is removed; if 7-Zip left partial output, the folder stays and a warning says so. * Password-protected archives fail immediately instead of waiting for a password, because 7-Zip's input is closed. `$LASTEXITCODE` ends up 0 if every target succeeded, 1 if any failed, and 2 for usage errors (no targets, unknown option). You can also run the script without loading it, for example from a scheduled task or `cmd.exe`. Run that way, its exit code is the same status: ```powershell & "$HOME\Scripts\unpack.ps1" C:\Downloads ``` ```bat powershell -NoProfile -File "%USERPROFILE%\Scripts\unpack.ps1" C:\Downloads ``` ## 7. Differences From unpack.sh * **7-Zip for everything.** The Linux version uses `tar`, `gzip`, `bzip2`, `xz`, `zstd` and prefers `unrar` for RAR. Here 7-Zip does all of it, so there is no `unrar` fallback for the rare RAR that 7-Zip cannot read. * **Compressed tarballs take two passes.** 7-Zip strips one layer at a time, so `.tar.gz` and friends are first expanded to a `.tar` in a temporary `.unpack-stage` folder inside the destination, then unpacked from there and the stage is deleted. Expect to need free space for the uncompressed `.tar` plus its contents; `tar` on Linux streams and needs only the latter. * **Options are PowerShell parameters.** The short forms work as aliases, but they cannot be bundled (`-k -n`, not `-kn`), and they may appear after the targets as well as before. `-v` is PowerShell's own `-Verbose`, so `-vb` works too. * **Wildcards are expanded by the function.** PowerShell does not glob arguments for functions the way bash does for every command, so `unpack` expands `*.zip` itself. An exact file name always wins, so names containing `[` `]` are taken literally. * **Status lives in `$LASTEXITCODE`.** A PowerShell function has no exit status. A malformed `-d` value (`-d abc`, `-d -1`) is rejected by PowerShell's parameter binder with its own error rather than status 2. * **Output.** Messages show full paths, and warnings go to PowerShell's warning stream (`WARNING: unpack: ...`) instead of stderr, so they can be silenced with `-WarningAction SilentlyContinue` or captured with `3>`. * **Scan order.** Folders are scanned in name order, while `find` returns directory order. The only visible effect is which of two same-named archives gets the `_1` suffix.