1.0.35
This commit is contained in:
17 files changed
+284
-203
No files matched your search
@@ -1,4 +1,17 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.35",
|
||||
"date": "2026-06-19",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "image prune skips Dockhand's scanner images (grype, trivy), configurable (#625)" },
|
||||
{ "type": "fix", "text": "\"No archive\" download format selection not persisted (#1180)" },
|
||||
{ "type": "fix", "text": "regression: vulnerability scans on direct-TCP envs (#1195)" },
|
||||
{ "type": "fix", "text": "Pangolin labels — recognise the real `public-resources` / `private-resources` namespaces (#2)" },
|
||||
{ "type": "fix", "text": "healthcheck uses HTTPS probe when HTTPS_MODE=on (#1191)" },
|
||||
{ "type": "fix", "text": "shell detection improved for containers with non-standard PATH or shell locations (#1189)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.35"
|
||||
},
|
||||
{
|
||||
"version": "1.0.34",
|
||||
"date": "2026-06-17",
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
},
|
||||
{
|
||||
"name": "js-yaml",
|
||||
"version": "4.1.1",
|
||||
"version": "4.2.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodeca/js-yaml"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as https from 'node:https';
|
||||
import * as tls from 'node:tls';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord } from './db';
|
||||
import { getStackEnvVarsAsRecord, getSetting } from './db';
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
import { encodeRegistryAuth } from './registry-auth';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
@@ -4271,11 +4271,69 @@ export async function pruneContainers(envId?: number | null) {
|
||||
}
|
||||
|
||||
export async function pruneImages(dangling = true, envId?: number | null) {
|
||||
// dangling=true: only remove untagged images (default Docker behavior)
|
||||
// dangling=false: remove ALL unused images including tagged ones
|
||||
// Docker API quirk: to remove all unused, we pass dangling=false filter
|
||||
const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}';
|
||||
return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId);
|
||||
// dangling=true: only remove untagged images. Pass straight through —
|
||||
// scanner images are always tagged so they can't be dangling anyway.
|
||||
if (dangling) {
|
||||
return dockerJsonRequest(
|
||||
`/images/prune?filters=${encodeURIComponent('{"dangling":["true"]}')}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
// dangling=false: "prune all unused." When the scanner-protection setting
|
||||
// is on, shield grype + trivy with stopped holder containers (Docker's
|
||||
// "in use" check keeps them) then tear them down in a finally (#625).
|
||||
const protect = (await getSetting('protect_scanner_images')) !== false;
|
||||
if (!protect) {
|
||||
return dockerJsonRequest(
|
||||
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner');
|
||||
const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE;
|
||||
const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE;
|
||||
|
||||
const holderIds: string[] = [];
|
||||
for (const image of [grypeImg, trivyImg]) {
|
||||
try {
|
||||
const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
|
||||
const created = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(safeName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ Image: image, Cmd: ['/bin/true'] })
|
||||
},
|
||||
envId
|
||||
);
|
||||
holderIds.push(created.Id);
|
||||
} catch {
|
||||
// Image not present locally → nothing to protect → no-op
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await dockerJsonRequest(
|
||||
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
);
|
||||
} finally {
|
||||
for (const id of holderIds) {
|
||||
try {
|
||||
await dockerJsonRequest(
|
||||
`/containers/${id}?force=true`,
|
||||
{ method: 'DELETE' },
|
||||
envId
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup; orphan holders are visible but harmless
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneVolumes(envId?: number | null) {
|
||||
|
||||
@@ -669,31 +669,9 @@ async function runScannerContainerCore(
|
||||
// detection failure — no regression for stock Docker hosts.
|
||||
hostSocketPath = await detectRemoteSocketPath(envId);
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`);
|
||||
} else if (connectionType === 'direct' && env?.host) {
|
||||
// Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP).
|
||||
// The scanner container is created on the REMOTE daemon, not on
|
||||
// Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's
|
||||
// host into a remote-daemon container is nonsense — on remote Docker
|
||||
// it silently creates an empty dir (scans fall back to registry); on
|
||||
// rootless Podman it errors with "mkdir /var/run/docker.sock:
|
||||
// permission denied" (#1076, #1011). Instead, tell the scanner to
|
||||
// talk to the daemon over the same TCP endpoint Dockhand uses.
|
||||
// `host.containers.internal` resolves to the daemon's host from
|
||||
// inside the scanner container on both Docker (with `host-gateway`)
|
||||
// and Podman (built-in).
|
||||
scannerDockerHost = `tcp://host.containers.internal:${env.port}`;
|
||||
// Add the host-gateway mapping so Docker honours the hostname.
|
||||
// Podman recognises host.containers.internal natively; the extra
|
||||
// mapping is harmless there.
|
||||
scannerExtraHosts = [
|
||||
...(scannerExtraHosts ?? []),
|
||||
'host.containers.internal:host-gateway'
|
||||
];
|
||||
console.log(
|
||||
`[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)`
|
||||
);
|
||||
} else {
|
||||
// Local socket — detect host socket path (handles rootless Docker)
|
||||
// Local socket — handles rootless Docker and direct-TCP envs
|
||||
// (the latter via the same socket bind; see #1195, #1076).
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Filename gate for stack writes (#1196).
|
||||
*
|
||||
* Lives in its own file so unit tests can import the helper without pulling
|
||||
* in stacks.ts (which transitively imports the DB layer and won't load
|
||||
* under bun:test).
|
||||
*
|
||||
* v1.0.34 locked compose/env paths to a hardcoded set (docker-compose.yml,
|
||||
* compose.yml, .env) which broke users who name their compose files after
|
||||
* the service (e.g. `headscale.yml`). v1.0.35 relaxes to any .yml / .yaml
|
||||
* file plus the `.env` family. The load-bearing security control is the
|
||||
* containment + symlink-realpath + traversal checks in validateStackPath() —
|
||||
* this gate is belt-and-suspenders to keep the write path producing
|
||||
* stack-shaped files. Still blocks authorized_keys, evil.sh, /etc/cron.d/x.
|
||||
*/
|
||||
|
||||
// Matches: foo.yml, foo.yaml, .env, foo.env, .env.local, prod.env.staging
|
||||
export const STACK_FILENAME_RE = /\.ya?ml$|(^|\.)env(\.|$)/;
|
||||
|
||||
export function isAllowedStackFilename(filename: string): boolean {
|
||||
return STACK_FILENAME_RE.test(filename);
|
||||
}
|
||||
+18
-95
@@ -18,6 +18,7 @@ import {
|
||||
type DeletionApplyResult,
|
||||
type DeletionSkipReason
|
||||
} from './git-deletions';
|
||||
import { isAllowedStackFilename } from './stack-filename';
|
||||
import {
|
||||
getEnvironment,
|
||||
getSecretEnvVarsAsRecord,
|
||||
@@ -34,9 +35,7 @@ import {
|
||||
removePendingContainerUpdate,
|
||||
deleteAutoUpdateSchedule,
|
||||
getAutoUpdateSetting,
|
||||
getStackSourceByComposePath,
|
||||
getExternalStackPaths,
|
||||
addExternalStackPath
|
||||
getStackSourceByComposePath
|
||||
} from './db';
|
||||
import { unregisterSchedule } from './scheduler';
|
||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||
@@ -350,19 +349,6 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
|
||||
return join(stacksDir, stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filenames a stack is allowed to write. Compose files in the conventional
|
||||
* names + .env. Anything else is rejected even when the directory is in
|
||||
* the allowlist, so this code path can only ever produce stack-shaped files.
|
||||
*/
|
||||
const ALLOWED_STACK_FILENAMES = new Set([
|
||||
'docker-compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'compose.yml',
|
||||
'compose.yaml',
|
||||
'.env'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve a path against the parent's realpath when the parent exists, so
|
||||
* symlinks resolve to their canonical location. We can't realpath the leaf
|
||||
@@ -381,13 +367,6 @@ function resolveStackPath(input: string): string {
|
||||
return abs;
|
||||
}
|
||||
|
||||
function isInside(child: string, parent: string): boolean {
|
||||
const c = pathNormalize(child);
|
||||
const p = pathNormalize(parent);
|
||||
if (c === p) return true;
|
||||
return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep);
|
||||
}
|
||||
|
||||
export interface StackPathValidation {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -396,15 +375,10 @@ export interface StackPathValidation {
|
||||
|
||||
/**
|
||||
* Validate that a custom compose or env file path is writable by this code
|
||||
* path. A path is accepted when it lives inside getStacksDir() or any
|
||||
* directory in the external_stack_paths allowlist (admin-controlled, set
|
||||
* in Settings → General), its filename is one of the conventional
|
||||
* compose/env names, and it does not contain .. segments. The parent is
|
||||
* resolved via realpath so a symlinked component inside an allowlisted dir
|
||||
* cannot point elsewhere.
|
||||
*
|
||||
* Callers that need to grandfather an existing admin-configured path should
|
||||
* call validateStackPathWithGrandfather() instead.
|
||||
* path. A path is accepted when:
|
||||
* - filename matches the stack-filename gate (.yml/.yaml/.env family)
|
||||
* - normalized form contains no .. segments (parent directory resolved
|
||||
* via realpath so a symlinked component can't smuggle traversal in)
|
||||
*/
|
||||
export async function validateStackPath(input: string): Promise<StackPathValidation> {
|
||||
if (!input || typeof input !== 'string') {
|
||||
@@ -420,53 +394,14 @@ export async function validateStackPath(input: string): Promise<StackPathValidat
|
||||
}
|
||||
|
||||
const filename = basename(resolvedPath);
|
||||
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
|
||||
if (!isAllowedStackFilename(filename)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
|
||||
error: `File "${filename}" is not an allowed stack filename (must end in .yml, .yaml, or .env)`
|
||||
};
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
if (isInside(resolvedPath, stacksDir)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
const allowlist = await getExternalStackPaths();
|
||||
for (const dir of allowlist) {
|
||||
if (!dir) continue;
|
||||
const dirResolved = resolve(dir);
|
||||
if (isInside(resolvedPath, dirResolved)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as validateStackPath, but when the path comes from an existing
|
||||
* stack source row (a stored custom path the admin set previously) and
|
||||
* fails the allowlist check, add its parent dir to external_stack_paths
|
||||
* and re-validate. Lets old custom-path configurations keep working
|
||||
* without operator intervention.
|
||||
*/
|
||||
export async function validateStackPathWithGrandfather(
|
||||
input: string,
|
||||
isPreExisting: boolean
|
||||
): Promise<StackPathValidation> {
|
||||
const first = await validateStackPath(input);
|
||||
if (first.ok || !isPreExisting) return first;
|
||||
|
||||
const parent = dirname(resolveStackPath(input));
|
||||
const added = await addExternalStackPath(parent);
|
||||
if (added) {
|
||||
console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`);
|
||||
}
|
||||
return validateStackPath(input);
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,28 +610,16 @@ export async function saveStackComposeFile(
|
||||
const source = await getStackSource(name, envId);
|
||||
const composePath = options?.composePath || source?.composePath;
|
||||
|
||||
// Path-allowlist validation. Pre-existing stored paths grandfather into
|
||||
// the allowlist; new caller-supplied paths must already be inside it.
|
||||
// Validate every caller-supplied or stored path before any disk write.
|
||||
// See validateStackPath() docs.
|
||||
if (composePath) {
|
||||
const fromDb = !options?.composePath && !!source?.composePath;
|
||||
const v = await validateStackPathWithGrandfather(composePath, fromDb);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.envPath) {
|
||||
const v = await validateStackPath(options.envPath);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
} else if (source?.envPath && !options?.envPath) {
|
||||
// Grandfather a DB-stored env path that may have been configured before validation.
|
||||
const v = await validateStackPathWithGrandfather(source.envPath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldComposePath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldComposePath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldEnvPath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldEnvPath, true);
|
||||
const pathsToCheck = [
|
||||
composePath,
|
||||
options?.envPath ?? source?.envPath,
|
||||
options?.oldComposePath,
|
||||
options?.oldEnvPath
|
||||
].filter((p): p is string => !!p);
|
||||
for (const path of pathsToCheck) {
|
||||
const v = await validateStackPath(path);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface AppSettings {
|
||||
labelFilterMode: LabelFilterMode;
|
||||
honorProxyLabels: boolean;
|
||||
showImageChangelogLinks: boolean;
|
||||
protectScannerImages: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -76,6 +77,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
labelFilterMode: 'any',
|
||||
honorProxyLabels: true,
|
||||
showImageChangelogLinks: true,
|
||||
protectScannerImages: true,
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -157,7 +159,8 @@ function createSettingsStore() {
|
||||
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -210,7 +213,8 @@ function createSettingsStore() {
|
||||
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -461,6 +465,13 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setProtectScannerImages: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, protectScannerImages: value };
|
||||
saveSettings({ protectScannerImages: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setShowImageChangelogLinks: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, showImageChangelogLinks: value };
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
* Pangolin label → public URL extraction (#2 follow-up).
|
||||
*
|
||||
* Pangolin Blueprints (https://docs.pangolin.net/manage/blueprints) annotates
|
||||
* a container with one or more proxy resources. The relevant labels for URL
|
||||
* extraction are:
|
||||
* a container with one or more resources, scoped public OR private. The
|
||||
* relevant labels for URL extraction are:
|
||||
*
|
||||
* pangolin.proxy-resources.<name>.name human-friendly label
|
||||
* pangolin.proxy-resources.<name>.full-domain public hostname (mandatory)
|
||||
* pangolin.proxy-resources.<name>.protocol http | https (defaults to https)
|
||||
* pangolin.public-resources.<name>.name human-friendly label
|
||||
* pangolin.public-resources.<name>.full-domain public hostname (mandatory)
|
||||
* pangolin.public-resources.<name>.protocol http | https (defaults to https)
|
||||
* pangolin.private-resources.<name>.{name,full-domain,protocol} private equivalents
|
||||
*
|
||||
* Both scopes resolve to a URL the user can click. The scope (`public`/
|
||||
* `private`) is preserved on the result so callers can label or filter.
|
||||
*
|
||||
* The `targets[N].port` family is intentionally ignored — Pangolin terminates
|
||||
* the public connection at full-domain; the internal target port is not part
|
||||
* of the URL a user sees.
|
||||
* the connection at full-domain; the internal target port is not part of the
|
||||
* URL a user sees.
|
||||
*
|
||||
* Returns one URL per resource that declares a full-domain. Multiple
|
||||
* resources on the same container yield multiple URLs. Identical URLs across
|
||||
@@ -24,32 +28,37 @@ export interface PangolinUrl {
|
||||
url: string;
|
||||
/** The Pangolin resource key (the `<name>` in the label key). */
|
||||
resource: string;
|
||||
/** Scope from the label namespace — public or private. */
|
||||
scope: 'public' | 'private';
|
||||
/** Optional human-friendly name from the `.name` label, if set. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const RESOURCE_KEY_RE =
|
||||
/^pangolin\.proxy-resources\.([^.]+)\.(full-domain|protocol|name)$/;
|
||||
/^pangolin\.(public|private)-resources\.([^.]+)\.(full-domain|protocol|name)$/;
|
||||
|
||||
export function extractPangolinUrls(
|
||||
labels: Record<string, string> | undefined | null
|
||||
): PangolinUrl[] {
|
||||
if (!labels) return [];
|
||||
|
||||
// Group label values by resource key.
|
||||
// Group label values by (scope, resource) key. A single resource name can
|
||||
// legitimately appear under both scopes (rare but allowed by Pangolin);
|
||||
// they're treated as independent resources here.
|
||||
const byResource = new Map<
|
||||
string,
|
||||
{ fullDomain?: string; protocol?: string; name?: string }
|
||||
{ scope: 'public' | 'private'; resource: string; fullDomain?: string; protocol?: string; name?: string }
|
||||
>();
|
||||
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
const m = key.match(RESOURCE_KEY_RE);
|
||||
if (!m) continue;
|
||||
const [, resource, field] = m;
|
||||
let entry = byResource.get(resource);
|
||||
const [, scope, resource, field] = m as unknown as [string, 'public' | 'private', string, string];
|
||||
const groupKey = `${scope}:${resource}`;
|
||||
let entry = byResource.get(groupKey);
|
||||
if (!entry) {
|
||||
entry = {};
|
||||
byResource.set(resource, entry);
|
||||
entry = { scope, resource };
|
||||
byResource.set(groupKey, entry);
|
||||
}
|
||||
const v = (value ?? '').trim();
|
||||
if (!v) continue;
|
||||
@@ -61,7 +70,7 @@ export function extractPangolinUrls(
|
||||
const out: PangolinUrl[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [resource, entry] of byResource) {
|
||||
for (const entry of byResource.values()) {
|
||||
if (!entry.fullDomain) continue;
|
||||
|
||||
const proto =
|
||||
@@ -75,7 +84,8 @@ export function extractPangolinUrls(
|
||||
|
||||
out.push({
|
||||
url,
|
||||
resource,
|
||||
resource: entry.resource,
|
||||
scope: entry.scope,
|
||||
displayName: entry.name
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,16 +135,24 @@ export async function detectShells(
|
||||
/**
|
||||
* Get the best available shell from the detection result
|
||||
* Returns the user's preferred shell if available, otherwise the default
|
||||
*
|
||||
* Preference matching is by shell name (basename), not full path — so a
|
||||
* preference of `/bin/bash` will still match when the server resolved bash
|
||||
* to a non-standard path like `/usr/bin/bash` (issue #1189).
|
||||
*/
|
||||
export function getBestShell(
|
||||
result: ShellDetectionResult,
|
||||
preferredShell: string
|
||||
): string | null {
|
||||
// If preferred shell is available, use it
|
||||
if (result.shells.includes(preferredShell)) {
|
||||
return preferredShell;
|
||||
const exact = result.shells.find(s => s === preferredShell);
|
||||
if (exact) return exact;
|
||||
|
||||
const preferredName = preferredShell.split('/').pop();
|
||||
if (preferredName) {
|
||||
const byName = result.shells.find(s => s.split('/').pop() === preferredName);
|
||||
if (byName) return byName;
|
||||
}
|
||||
// Otherwise use the default shell
|
||||
|
||||
return result.defaultShell;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user