This commit is contained in:
jarek committed 2026-07-25 08:41:01 +02:00
1 parent 9f09d60a83
commit 64b6dcd744
177 files changed
+21182 -1042

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
/**
* Pure decision for the "Backup helper image" setting in Settings → Backups.
*
* The field is pre-filled with the value the API returned — either the user's
* saved override, or (when nothing is saved) the engine's real version-pinned
* default (fnsys/dockhand-backup:<version>). We must persist ONLY when the user
* actually changed it: saving the pre-filled versioned default would pin the DB to
* that exact version, and since the DB setting wins over the computed default, a
* later app upgrade would keep using the OLD helper (a stale helper — see
* src/lib/server/backups/restic.ts). Not saving an unchanged value keeps the DB row
* empty so the engine tracks the app version automatically.
*/
/**
* Decide whether to persist the backup helper image on Save.
*
* @param current the current field value (trimmed comparison; leading/trailing
* whitespace never counts as a change)
* @param initial the value the field was loaded with (API's saved-or-default)
* @returns true only when the user meaningfully changed the value
*/
export function shouldSaveBackupImage(current: string, initial: string): boolean {
return (current ?? '').trim() !== (initial ?? '').trim();
}
+387
View File
@@ -0,0 +1,387 @@
/**
* Shared backup utilities — used by BackupPanel, BackupsTab, RestoreModal, backups page.
*/
import { HardDrive, Globe } from 'lucide-svelte';
import { AmazonS3Icon, BackblazeIcon, AzureBlobIcon, GoogleCloudIcon, RestServerIcon } from '$lib/components/cloud-icons';
import cronstrue from 'cronstrue';
import type { Component } from 'svelte';
import { computeExecutionTally, type Execution, type ExecutionTally } from '$lib/utils/execution-tally';
export { computeExecutionTally, type Execution, type ExecutionTally };
// ---------------------------------------------------------------------------
// Job-result classification
// ---------------------------------------------------------------------------
/**
* Classify a backup/restore job result. The engine now returns a discriminated
* union keyed on `status` ('success' | 'warning' | 'skipped' | 'error') — it no
* longer carries the old `{ success: boolean }` field. A 'skipped' result (a
* concurrent-run rejection, or a retention "would wipe everything" guard) is
* NOT a success and must not be reported as one; it is also not a hard error.
*
* `error` covers both the current shape and any legacy `{ success:false }` /
* bare `{ error }` payload, so this is safe for callers that may still receive
* an old-shaped result from a non-migrated route.
*/
export type BackupOutcome = 'success' | 'warning' | 'skipped' | 'error';
export function classifyJobResult(result: any): { outcome: BackupOutcome; message?: string } {
if (!result || typeof result !== 'object') return { outcome: 'error', message: 'No result' };
const status = result.status as string | undefined;
if (status === 'error' || result.success === false || (result.error && !status)) {
return { outcome: 'error', message: result.error || 'Operation failed' };
}
if (status === 'skipped') return { outcome: 'skipped', message: result.reason };
if (status === 'warning') return { outcome: 'warning', message: result.warning };
return { outcome: 'success' };
}
// ---------------------------------------------------------------------------
// Repository type helpers
// ---------------------------------------------------------------------------
export function getRepoTypeIcon(repository: string): Component {
if (repository.startsWith('/') || repository.startsWith('./')) return HardDrive;
if (repository.startsWith('s3:')) return AmazonS3Icon;
if (repository.startsWith('b2:')) return BackblazeIcon;
if (repository.startsWith('azure:')) return AzureBlobIcon;
if (repository.startsWith('gs:')) return GoogleCloudIcon;
if (repository.startsWith('rest:')) return RestServerIcon;
return Globe;
}
export function getRepoTypeLabel(repository: string): string {
if (repository.startsWith('/') || repository.startsWith('./')) return 'Local';
if (repository.startsWith('s3:')) return 'S3';
if (repository.startsWith('b2:')) return 'Backblaze B2';
if (repository.startsWith('azure:')) return 'Azure Blob';
if (repository.startsWith('gs:')) return 'Google Cloud';
if (repository.startsWith('rest:')) return 'REST';
return 'Unknown';
}
// Repository/environment predicates live in the lucide-free shared module so
// server routes can import them without pulling in this file's icon deps (#37).
export { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
export {
normalizeMounts,
normalizeStackMounts,
mountTypeFromHostPath,
groupContainersForBackup,
volumesForStack,
type VolumeInfo,
type BackupItem,
type RawContainer
} from '$lib/utils/mounts';
// ---------------------------------------------------------------------------
// Retention helpers
// ---------------------------------------------------------------------------
export interface RetentionPolicy {
keepLast?: number;
keepDaily?: number;
keepWeekly?: number;
keepMonthly?: number;
keepYearly?: number;
}
export interface BackupOptions {
compression?: string;
limitUpload?: string;
limitDownload?: string;
excludePatterns?: string;
excludeCaches?: boolean;
webhookSuccess?: string;
webhookFailure?: string;
}
export function parseRetention(retention: string | RetentionPolicy | null | undefined): RetentionPolicy {
if (!retention) return {};
if (typeof retention === 'object') return retention;
try {
const parsed = JSON.parse(retention);
// Extract only retention fields (ignore any extra properties)
const { keepLast, keepDaily, keepWeekly, keepMonthly, keepYearly } = parsed;
return { keepLast, keepDaily, keepWeekly, keepMonthly, keepYearly };
} catch { return {}; }
}
export function parseOptions(options: string | BackupOptions | null | undefined): BackupOptions {
if (!options) return {};
if (typeof options === 'object') return options;
try { return JSON.parse(options); } catch { return {}; }
}
export function retentionSummary(retention: string | RetentionPolicy | null | undefined): string {
const r = parseRetention(retention);
const parts = [
r.keepLast && `${r.keepLast} last`,
r.keepDaily && `${r.keepDaily}d`,
r.keepWeekly && `${r.keepWeekly}w`,
r.keepMonthly && `${r.keepMonthly}m`,
r.keepYearly && `${r.keepYearly}y`
].filter(Boolean);
return parts.length > 0 ? `Keep ${parts.join('/')}` : '';
}
// ---------------------------------------------------------------------------
// Cron helpers
// ---------------------------------------------------------------------------
export function formatCron(cron: string | null): string {
if (!cron) return '—';
try { return cronstrue.toString(cron, { use24HourTimeFormat: true }); } catch { return cron; }
}
// ---------------------------------------------------------------------------
// Config body assembly + action orchestration
//
// Shared between BackupPanel (the in-modal Backups tab) and CreateBackupModal
// (the standalone wizard) so neither can silently drift on what gets POSTed.
//
// The historical bug this exists to prevent: BackupPanel's old "Run once"
// path silently omitted selectedVolumes, letting the backend default to
// allVolumes=true. With buildBackupConfigBody as the single source of truth,
// any field added below is automatically sent by every action.
// ---------------------------------------------------------------------------
/**
* Form state required to construct a /api/backup/configs body. `retention`
* and `options` are optional — the wizard (CreateBackupModal) doesn't expose
* them, the in-modal form (BackupPanel) does.
*/
export interface BackupFormState {
targetName: string;
type: 'container' | 'stack';
environmentId: number | null | undefined;
destinationId: number;
stopBeforeBackup: boolean;
allVolumes: boolean;
selectedVolumes: string[];
retention?: RetentionPolicy;
options?: BackupOptions;
}
/**
* Decides what happens after the config exists:
* save — POST/PUT and stop. Schedule field is honored, enabled flag honored.
* save-run — same as save, then immediately fires /run on the saved config.
* run-once — POSTs a disabled config with no schedule, runs it, then deletes
* it. Snapshot remains in the repo (browseable, restorable as an
* orphan) but no zombie config row is left behind.
*/
export type BackupAction = 'save' | 'save-run' | 'run-once';
export interface BuildBodyOptions {
enabled: boolean;
/** Set to null to explicitly clear the schedule (run-once needs this). */
schedule: string | null;
}
/** Pure: assemble the POST/PUT body. Single source of truth. */
export function buildBackupConfigBody(
form: BackupFormState,
opts: BuildBodyOptions
): Record<string, any> {
return {
targetName: form.targetName,
type: form.type,
destinationId: form.destinationId,
environmentId: form.environmentId,
enabled: opts.enabled,
schedule: opts.schedule,
retention: form.retention,
options: form.options,
stopBeforeBackup: form.stopBeforeBackup,
// Persist allVolumes explicitly (audit #38) — the server's selection filter
// keys on `!config.allVolumes`, so omitting it here left the DB column stale
// on update and could apply/skip the filter incorrectly.
allVolumes: form.allVolumes,
selectedVolumes: form.allVolumes ? null : form.selectedVolumes
};
}
export interface RunBackupActionInput {
form: BackupFormState;
action: BackupAction;
/** Existing config to update (PUT). Omit for new-config flow (POST). */
editingConfigId?: number | null;
/** Cron expression to persist when action !== 'run-once'. Ignored for run-once. */
schedule: string;
enabled: boolean;
/** Streams restic progress lines from the /run job. No-op by default. */
onProgress?: (line: { event?: string; data: unknown }) => void;
/** Called with the config id once the backup is about to run, so the caller can
* offer a Cancel button while it's in flight. */
onStarted?: (configId: number) => void;
}
export interface RunBackupActionResult {
ok: boolean;
configId?: number;
snapshotId?: string;
error?: string;
}
/**
* Orchestrate the full action sequence: POST/PUT the config, optionally /run
* it, optionally delete it. Returns a tagged result so callers can decide what
* toast to fire and what to do with the result.
*
* Errors are returned in the result rather than thrown — the caller has more
* context (which target, which environment) to render a useful error toast.
*/
export async function runBackupAction(input: RunBackupActionInput): Promise<RunBackupActionResult> {
const { form, action, editingConfigId, onProgress } = input;
// Build the body that matches the requested action. Run-once forces
// enabled=false + schedule=null regardless of what the user typed.
const persistedBody = buildBackupConfigBody(form, {
enabled: action === 'run-once' ? false : input.enabled,
schedule: action === 'run-once' ? null : (input.schedule || null)
});
// A run-once now KEEPS its config, so running one twice for the same target
// must not pile up duplicate configs. Reuse an existing config for the same
// (target, type, env, destination) if one is already there — PUT it instead of
// POSTing a new one. (Edits already carry editingConfigId and skip this.)
let reuseId = editingConfigId ?? null;
if (action === 'run-once' && reuseId == null) {
try {
const params = new URLSearchParams({ type: form.type, target: form.targetName });
if (form.environmentId != null) params.set('env', String(form.environmentId));
const res = await fetch(`/api/backup/configs?${params}`);
if (res.ok) {
const list = await res.json();
const arr = Array.isArray(list) ? list : (list?.id ? [list] : []);
const match = arr.find((c: any) => c.destinationId === form.destinationId);
if (match?.id) reuseId = match.id;
}
} catch { /* fall through to POST a fresh config */ }
}
// Step 1 — persist. PUT when we have a config to reuse (edit or a matching
// run-once target), POST otherwise.
let configId: number;
try {
const isPut = reuseId != null;
const url = isPut ? `/api/backup/configs/${reuseId}` : '/api/backup/configs';
const res = await fetch(url, {
method: isPut ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(persistedBody)
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return { ok: false, error: data.error || `Failed to ${isPut ? 'update' : 'create'} backup config` };
}
const body = await res.json();
configId = isPut ? (reuseId as number) : body.id;
} catch (err: any) {
return { ok: false, error: err?.message || 'Failed to save config' };
}
// Step 2 — if just saving, we're done.
if (action === 'save') return { ok: true, configId };
// Step 3 — run the backup. Both 'save-run' and 'run-once' land here.
let snapshotId: string | undefined;
let runError: string | undefined;
// Report the config id now so the caller can offer a Cancel while it runs.
input.onStarted?.(configId);
try {
// When the caller wants live output (onProgress), ask for the JOB-POLLING path
// (returns { jobId } → we stream via watchJob). `Accept: application/json` takes
// the SYNCHRONOUS server path instead, which produces NO progress lines — that's
// only for tests/CLI that don't stream. Requesting the wrong one is why the log
// modal was empty ("Backup completed" but 0 lines).
const wantsStream = typeof onProgress === 'function';
const runRes = await fetch(`/api/backup/configs/${configId}/run`, {
method: 'POST',
headers: { 'Accept': wantsStream ? 'text/event-stream' : 'application/json' }
});
const runData = await runRes.json().catch(() => ({}));
if (runData.jobId) {
// SSE-style job — poll until done. onProgress streams progress lines.
// Imported lazily so this module stays Svelte-runtime-free at the
// top level (callers in non-browser contexts can still buildBody).
const { watchJob } = await import('$lib/utils/sse-fetch');
const result: any = await watchJob(runData.jobId, (line) => { onProgress?.(line); });
const { outcome, message } = classifyJobResult(result);
// 'skipped' (concurrent overlap / prune guard) is neither success nor a
// hard failure — surface the reason so the caller isn't told it succeeded.
if (outcome === 'error' || outcome === 'skipped') {
runError = message || 'Backup failed';
} else {
snapshotId = result?.snapshotId;
}
} else {
// Synchronous JSON path (Accept: application/json). Same union shape.
const { outcome, message } = classifyJobResult(runData);
if (outcome === 'error' || outcome === 'skipped') {
runError = message || 'Backup failed';
} else {
snapshotId = runData.snapshotId;
}
}
} catch (err: any) {
runError = err?.message || 'Backup failed';
}
// A 'run-once' backup KEEPS its config (enabled=false, schedule=null) so the
// target stays on the backups list with a working "Run backup now" button and a
// delete action. Previously the config was deleted here, which turned every
// one-off backup into an orphan (no re-run possible). The user removes it
// explicitly via the delete action when they no longer want it.
if (runError) return { ok: false, configId, error: runError };
return { ok: true, configId, snapshotId };
}
/**
* Tag a progress line for LogConsole's source pills. Dockhand's own status lines
* carry no `[source]` prefix, so tag them `[dockhand]`; restic/FS-op lines already
* arrive tagged (`[restic]`/`[dockhand]`) and pass through unchanged. Shared by
* every place that streams backup/restore progress into LogConsole.
*/
export function tagLogLine(msg: string): string {
return /^\[[a-z0-9_-]+\]/i.test(msg) ? msg : `[dockhand] ${msg}`;
}
// ---------------------------------------------------------------------------
// Backup execution history + ok/fail tally
// ---------------------------------------------------------------------------
/**
* Fetch backup executions for a target's config ids and compute an ok/fail tally.
* Used by the container/stack modal (tab counters + History tab) and the main
* Backups page. Merges across multiple configs (a target can back up to more than
* one repo). Network shell around {@link computeExecutionTally}.
*
* Pass `repoByConfig` (configId → {repository, name}) to stamp each run with which
* repo it wrote to — the History table shows a Repo column when this is present.
*/
export async function fetchBackupExecutions(
configIds: number[],
limit = 50,
repoByConfig?: Map<number, { repository?: string; name?: string }>
): Promise<ExecutionTally> {
if (!configIds.length) return { executions: [], ok: 0, failed: 0 };
const all: Execution[] = [];
await Promise.all(configIds.map(async (id) => {
try {
const res = await fetch(`/api/schedules/executions?scheduleType=backup&scheduleId=${id}&limit=${limit}`);
if (!res.ok) return;
const data = await res.json();
const dest = repoByConfig?.get(id);
for (const e of data.executions || []) {
const exec = e as Execution;
if (dest) { exec._repository = dest.repository; exec._destinationName = dest.name; }
all.push(exec);
}
} catch { /* one config's history is best-effort */ }
}));
return computeExecutionTally(all);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Pure, UI-free backup execution-history logic. Kept out of utils/backup.ts (which
* pulls in lucide/cloud-icons) so it can be unit-tested under `bun test` — matches
* the repo's convention of isolating logic from Svelte/icon imports.
*/
export interface Execution {
id: number;
scheduleId?: number;
triggeredAt: string | null;
triggeredBy: string;
status: string;
duration: number | null;
errorMessage: string | null;
logs?: string | null;
details?: { filesNew?: number; filesChanged?: number; dataAdded?: number } | null;
/** Stamped by fetchBackupExecutions when a config→destination map is passed, so
* the history table can show which repo each run wrote to. */
_repository?: string;
_destinationName?: string;
}
export interface ExecutionTally {
executions: Execution[];
ok: number; // success + warning + skipped — produced a snapshot or was a no-op
failed: number; // status === 'failed'
}
/**
* Sort executions newest-first and count the ok/fail tally. `warning` and `skipped`
* count as ok — only a hard `failed` is red. `running`/`queued` count as neither
* (in-flight, no outcome yet). A null `triggeredAt` sorts oldest.
*/
export function computeExecutionTally(executions: Execution[]): ExecutionTally {
const sorted = [...executions].sort(
(a, b) => new Date(b.triggeredAt ?? 0).getTime() - new Date(a.triggeredAt ?? 0).getTime()
);
let ok = 0, failed = 0;
for (const e of sorted) {
if (e.status === 'failed') failed++;
else if (e.status === 'success' || e.status === 'warning' || e.status === 'skipped') ok++;
}
return { executions: sorted, ok, failed };
}
+13
View File
@@ -1,6 +1,19 @@
/**
* Format a byte count into a human-readable string.
*/
export function formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)} ${units[i]}`;
}
/**
* Compact byte format for grids: 1.2K, 500M, 2.1G
*/
export function formatBytesCompact(bytes: number, decimals = 1): string {
if (bytes === 0) return '0B';
const units = ['B', 'K', 'M', 'G', 'T'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)}${units[i]}`;
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Canonical volume/bind normalization for the backup UI.
*
* Three surfaces (CreateBackupModal, BackupPanel via EditContainerModal, and
* EnvironmentBackupsTab) previously each open-coded "Docker mounts -> pickable
* volume list" and drifted: one dropped bind mounts entirely, one never set a
* mount type (so every row rendered as a named volume). Both symptoms trace to
* duplicated logic. This module is the single source of truth.
*
* Kept dependency-free (no Svelte/lucide/db imports) so it is unit-testable
* under `bun test`.
*/
export interface VolumeInfo {
name: string;
mountPoint: string;
mountType: 'volume' | 'bind';
}
/** A raw Docker mount as returned by the containers API (list or inspect).
* Both lowercase (our normalized) and PascalCase (raw Docker) keys are
* tolerated because callers pass either shape. */
interface RawMount {
type?: string;
Type?: string;
name?: string;
Name?: string;
source?: string;
Source?: string;
destination?: string;
Destination?: string;
}
/** Docker's Binds are `hostPath:containerPath[:mode]`; a named volume's
* "hostPath" is the volume NAME (no leading slash), a bind mount's is an
* absolute host path. This is the standard heuristic Docker itself uses. */
export function mountTypeFromHostPath(hostPath: string): 'volume' | 'bind' {
return hostPath.startsWith('/') ? 'bind' : 'volume';
}
/**
* Normalize a container's raw Docker mounts into the pickable volume list.
* Keeps only `volume` and `bind` mounts (skips tmpfs/npipe/etc.), and derives
* a stable display `name` (volume name, else source, else destination).
*/
export function normalizeMounts(mounts: RawMount[] | null | undefined): VolumeInfo[] {
if (!Array.isArray(mounts)) return [];
const out: VolumeInfo[] = [];
for (const m of mounts) {
const type = m.type || m.Type;
if (type !== 'volume' && type !== 'bind') continue;
const name = m.name || m.Name || m.source || m.Source || m.destination || m.Destination || '';
out.push({
name,
mountPoint: m.destination || m.Destination || '',
mountType: type === 'bind' ? 'bind' : 'volume'
});
}
return out;
}
/**
* Merge the mounts of every container in a compose project into one deduped
* volume list (by display name). Used for stack-level backup pickers.
*/
export function normalizeStackMounts(
containers: Array<{ mounts?: RawMount[]; Mounts?: RawMount[] }>
): VolumeInfo[] {
const seen = new Set<string>();
const out: VolumeInfo[] = [];
for (const c of containers) {
for (const v of normalizeMounts(c.mounts || c.Mounts)) {
if (v.name && !seen.has(v.name)) {
seen.add(v.name);
out.push(v);
}
}
}
return out;
}
/** The compose-project label that groups containers into a stack. */
const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project';
/** A raw container as returned by the /api/containers list endpoint. Tolerates
* the Docker-raw fallback keys (`Mounts`, `Names`) the same way the mount
* normalizers do, so callers can pass either shape. */
export interface RawContainer {
name?: string;
Names?: string[];
labels?: Record<string, string | undefined>;
Labels?: Record<string, string | undefined>;
mounts?: RawMount[];
Mounts?: RawMount[];
}
/** A pickable backup target: a stack (mounts of all its containers, merged) or
* a standalone container. */
export interface BackupItem {
name: string;
type: 'stack' | 'container';
volumes: VolumeInfo[];
}
function containerName(c: RawContainer): string {
return c.name || c.Names?.[0]?.replace(/^\//, '') || '';
}
function composeProject(c: RawContainer): string | undefined {
const labels = c.labels || c.Labels;
return labels?.[COMPOSE_PROJECT_LABEL];
}
/**
* Partition a raw /api/containers list into stack items (grouped by the compose
* project label, mounts merged+deduped across the stack's containers) and
* standalone container items. Sorted: stacks first, then containers, each
* alphabetical.
*
* NOTE: deriving stacks from container labels can't see a stack's sourceType and
* misses stopped stacks (no live containers). Backup-discovery surfaces now take
* stacks from /api/stacks and standalone containers from {@link standaloneContainers}.
* This whole-list grouping is kept for any caller that only has a container list.
*/
export function groupContainersForBackup(containers: RawContainer[] | null | undefined): BackupItem[] {
if (!Array.isArray(containers)) return [];
const stackContainers = new Map<string, RawContainer[]>();
const standalone: BackupItem[] = [];
for (const c of containers) {
const project = composeProject(c);
if (project) {
const existing = stackContainers.get(project) || [];
existing.push(c);
stackContainers.set(project, existing);
} else {
standalone.push({ name: containerName(c), type: 'container', volumes: normalizeMounts(c.mounts || c.Mounts) });
}
}
return [
...[...stackContainers.entries()].map(([name, cs]): BackupItem => ({
name,
type: 'stack',
volumes: normalizeStackMounts(cs)
})),
...standalone
].sort((a, b) => {
if (a.type !== b.type) return a.type === 'stack' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
/**
* Volumes for ONE stack: the stack slice of {@link groupContainersForBackup}.
* Returns [] if the stack has no containers (stopped / not-yet-deployed) — that
* is now the ONLY path that can legitimately produce an empty picker.
*/
export function volumesForStack(
containers: RawContainer[] | null | undefined,
stackName: string
): VolumeInfo[] {
if (!Array.isArray(containers)) return [];
const mine = containers.filter((c) => composeProject(c) === stackName);
return normalizeStackMounts(mine);
}
/**
* The STANDALONE-container slice of a /api/containers list: every container
* WITHOUT a compose-project label, as a backup item with its mounts normalized.
* Sorted alphabetically.
*
* This exists because the stack slice of a backup list must now come from
* /api/stacks (the single source that knows a stack's sourceType and includes
* stopped stacks) — reconstructing stacks from container labels missed both. So
* the caller takes stacks from /api/stacks and standalone containers from here,
* instead of {@link groupContainersForBackup} (which does both from labels).
*/
export function standaloneContainers(
containers: RawContainer[] | null | undefined
): BackupItem[] {
if (!Array.isArray(containers)) return [];
return containers
.filter((c) => !composeProject(c))
.map((c): BackupItem => ({
name: containerName(c),
type: 'container',
volumes: normalizeMounts(c.mounts || c.Mounts)
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Parse a shell command string into an array of arguments,
* respecting single and double quotes.
*
* Examples:
* "sleep 3600" → ["sleep", "3600"]
* 'sh -c "echo hello world"' → ["sh", "-c", "echo hello world"]
* "python -c 'print(1)'" → ["python", "-c", "print(1)"]
*/
export function parseShellCommand(cmd: string): string[] {
const args: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < cmd.length; i++) {
const char = cmd[i];
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar && inQuotes) {
inQuotes = false;
quoteChar = '';
} else if (char === ' ' && !inQuotes) {
if (current) {
args.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}