Skip to content

init - #1

Open
chasegiunta wants to merge 2 commits into
mainfrom
init-1
Open

init#1
chasegiunta wants to merge 2 commits into
mainfrom
init-1

Conversation

@chasegiunta

@chasegiunta chasegiunta commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces the initial implementation of the tug CLI tool, which provides a TypeScript/Bun-based command-line interface for Craft CMS deployment and sync workflows. The changes establish the project structure, core configuration and validation logic, command definitions, and supporting files for development and linting.

The most important changes are:

Core features and CLI structure:

  • Implements the main entry point (src/cli/main.ts) for the tug CLI, defining top-level commands and subcommands for initialization, diagnostics, file/folder sync, database operations, composer sync, backups, and remote shell access. The CLI supports both interactive TUI and subcommand-driven workflows.
  • Adds command modules for each workflow area (src/cli/commands/*.ts), including init, doctor, folder, db, composer, backups, and shell, each delegating to corresponding task logic and supporting shared arguments and runtime context. [1] [2] [3] [4] [5] [6] [7]
  • Provides a shared CLI utility module (src/cli/common.ts) for argument definitions, runtime loading, and standardized task result printing.

Configuration and diagnostics:

  • Implements configuration management (src/core/config.ts), including schema validation (with Zod), TOML parsing, example config rendering, and initial config file creation.
  • Adds a project diagnostics module (src/core/doctor.ts) that checks for required tools, configuration validity, and SSH/remote prerequisites, with formatted reporting for CLI and JSON output.

Project setup and tooling:

  • Adds a project manifest (package.json) with dependencies for Bun, OpenTUI, Citty, linting, and type checking, as well as scripts for development and testing.
  • Includes a sample .env.example file with Craft-style database variables for local development.
  • Provides a default linter configuration (oxlint.json) and a comprehensive README.md with usage, requirements, and development instructions. [1] [2]

Summary by CodeRabbit

  • New Features

    • New tug CLI + interactive TUI for Craft CMS deployments with tasks: folder sync (pull/push), database sync (pull/push), composer sync (pull/push), backups (list/open), and remote shell
    • Project health diagnostics (doctor) and support for MySQL/Postgres workflows
  • Documentation

    • Added comprehensive README with install, requirements, and command usage
  • Tests

    • New test suites for config, env resolution, database orchestration, folder sync, and process runner
  • Chores

    • Added example env template and project .gitignore and example config template

@coderabbitai

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown

Walkthrough

Adds a new Bun + TypeScript CLI/TUI project ("tug") for Craft CMS workflows: configuration, type definitions, runtime/process runner, task implementations (folder, database, composer, backups, shell), CLI commands, TUI, tests, and supporting tooling/config files.

Changes

Cohort / File(s) Summary
Project metadata & examples
package.json, README.md, tug.example.toml, .env.example, .gitignore, tsconfig.json, oxlint.json
Project manifest, docs, example config and env template, git ignores, TypeScript and linter configs, and CLI binary entrypoint.
Type declarations
src/types/index.ts
Introduces core public types/interfaces for config, runtime options, process commands, task plans/results, diagnostics, credentials, and runtime bundle.
Core runtime & utilities
src/core/...
src/core/config.ts, src/core/env.ts, src/core/paths.ts, src/core/process.ts, src/core/runtime.ts, src/core/logging.ts, src/core/shell.ts, src/core/doctor.ts
Configuration loading/writing with schema validation, env resolution (local/remote, Craft-style and DATABASE_URL), path/runtime helpers, Bun process runner, runtime factory, in-memory logger, shell quoting utilities, and project inspection/doctor reporting.
CLI infra & commands
src/cli/...
src/cli/main.ts, src/cli/common.ts, src/cli/commands/...
Top-level CLI/TUI launcher, shared CLI arg handling and runtime bootstrap, and command modules for init, doctor, folder, db, composer, shell, and backups.
Task system & implementations
src/tasks/...
src/tasks/index.ts, src/tasks/shared.ts, src/tasks/folder.ts, src/tasks/database.ts, src/tasks/composer.ts, src/tasks/shell.ts, src/tasks/backups.ts
Task descriptor registry, shared execution utilities (plan execution, destructive checks), and concrete task planners/executors for folder sync, database dump/import pipelines, composer sync, remote shell, and backups (rsync/ssh command generation and orchestration).
TUI code
src/tui/...
src/tui/app.ts, src/tui/components/confirmation-dialog.ts, src/tui/screens/dashboard.ts, src/tui/screens/task-runner.ts
Interactive TUI entrypoint, dashboard/details rendering, task-runner log view, and confirmation dialog formatting with logger integration and keyboard handling.
Tests & test helpers
tests/...
tests/helpers.ts, tests/config.test.ts, tests/env.test.ts, tests/process.test.ts, tests/doctor.test.ts, tests/folder.test.ts, tests/database.test.ts
Mock process runner, temp-project utilities, and suites validating config I/O, env credential parsing, Bun process runner behavior, doctor inspection, folder rsync generation, and database command orchestration.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI
    participant Runtime as LoadedRuntime
    participant Config as Config/Env
    participant TaskSystem as Task System
    participant Runner as Process Runner
    participant Remote as SSH/Remote

    User->>CLI: invoke tug (subcommand or TUI)
    CLI->>Runtime: createLoadedRuntime(options)
    Runtime->>Config: loadRuntimeConfig() / readLocalEnv()
    Config-->>Runtime: TugConfig + credentials
    CLI->>TaskSystem: plan(runtime, action)
    TaskSystem->>Config: resolveLocal/RemoteCredentials()
    Config-->>TaskSystem: DatabaseCredentials
    TaskSystem-->>CLI: TaskPlan (commands, artifacts)
    CLI->>TaskSystem: run(runtime, action)
    TaskSystem->>Runner: runner.run(commandSpec)
    Runner->>Remote: SSH / rsync / local binaries
    Remote-->>Runner: command results
    Runner-->>TaskSystem: stdout/stderr + exit codes
    TaskSystem-->>CLI: TaskResult
    CLI->>User: print summary / artifacts
Loading
sequenceDiagram
    participant User as TTY User
    participant TUI as TUI App
    participant Doctor as Doctor Inspector
    participant Runner as Process Runner
    participant Logger as MemoryLogger
    participant TaskSystem as Task Execution

    User->>TUI: startTui(options)
    TUI->>Runtime: createLoadedRuntime(interactive)
    TUI->>Doctor: inspectProject(async)
    Doctor->>Runner: run diagnostic commands (which, ssh checks)
    Runner-->>Doctor: tool availability results
    Doctor-->>TUI: DoctorReport
    User->>TUI: select task + confirm
    TUI->>Logger: clear() and subscribe()
    TUI->>TaskSystem: run(runtime, action)
    TaskSystem->>Runner: execute plan commands
    Runner->>Logger: stream stdout/stderr via hooks
    Logger-->>TUI: push log updates
    TUI->>User: render task-runner / status / lastSummary
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Hops and scripts beneath moonlight,

Sync and dump, then SSH flight.
TUI hums, the logger sings,
Backups, composer, careful things.
A rabbit nudges “tug” to run—deploy delight.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.01% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title "init" is vague and does not clearly summarize the primary change. While it may refer to the init command being added, it fails to convey the scope of this initial implementation which includes the entire CLI tool, configuration system, tasks, and TUI. Revise the title to better reflect the main deliverable, such as "Initial tug CLI tool implementation" or "Add tug CLI for Craft CMS deployment workflows" to provide meaningful context.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch init-1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🧹 Nitpick comments (11)
src/core/shell.ts (1)

5-6: Use shellQuote in joinCommand for unambiguous rendering.
Current output can be misleading for args containing spaces or shell-sensitive characters.

🛠️ Proposed refactor
 export function joinCommand(parts: string[]): string {
-  return parts.join(" ");
+  return parts.map(shellQuote).join(" ");
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/shell.ts` around lines 5 - 6, The joinCommand function currently
concatenates parts with spaces which can misrepresent args containing spaces or
shell-sensitive characters; update joinCommand to map each element through
shellQuote (use the existing shellQuote function) and then join the quoted parts
with a single space so every argument is safely and unambiguously rendered (also
ensure the function still accepts string[] and handles empty strings by quoting
them via shellQuote).
package.json (1)

26-26: Use an explicit semver range or pinned version instead of latest for @types/bun to keep installs reproducible.

Floating "latest" versions can resolve differently across CI runs, causing non-determinism and potential lockfile conflicts. Use explicit versions like "^1.3.11" or run bun add -d @types/bun`` to generate a consistent range.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 26, The package.json entry for the devDependency
"@types/bun" uses the floating tag "latest", which makes installs
non-reproducible; replace it with an explicit semver range or pinned version
(e.g., "^1.3.11" or "1.3.11") by updating the "@types/bun" value in package.json
or run the command `bun add -d `@types/bun`` to have Bun write a consistent range
into package.json and the lockfile; ensure you commit the updated package.json
and lockfile so CI and local installs are deterministic.
tests/config.test.ts (1)

21-26: Add an assertion for .env.sample copy behavior.

This test sets up .env.example but never verifies that writeInitialConfig copied it to .env.sample, so regressions in that path can slip through.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/config.test.ts` around lines 21 - 26, The test currently writes
".env.example" and calls writeInitialConfig but doesn't assert that
writeInitialConfig copied it to ".env.sample"; update the test (around where
writeProjectFile, writeInitialConfig, and readConfig are used) to assert that a
".env.sample" file exists in the test cwd and that its contents equal the
expected "DB_DATABASE=test\nDB_USER=root\n" (or match the contents of the
written .env.example) so regressions in the copy behavior are caught.
src/tasks/index.ts (1)

3-54: Consider consolidating task metadata with execution routing.

src/tui/app.ts dispatches by descriptor.id, while this file separately carries action. Keeping these in separate places can drift silently. A shared registry (descriptor + execute/confirm binding together) would reduce that risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/index.ts` around lines 3 - 54, The taskDescriptors array is only
metadata while dispatch logic in src/tui/app.ts branches on descriptor.id,
risking drift; modify the TaskDescriptor shape to include execution routing
(e.g., add an executor and optional confirmHandler fields) and update
taskDescriptors entries (folderPull, folderPush, databasePull, databasePush,
composerPull, composerPush, backups, terminal) to reference the appropriate
handler names; then change the dispatcher in src/tui/app.ts to call
descriptor.executor(descriptor) (and descriptor.confirmHandler when needed) so
descriptor.id and action stay colocated with their execution functions.
src/tasks/backups.ts (1)

16-24: Backups “open” action is currently macOS-specific.

Using open (and Finder wording) limits this command to macOS. Consider platform-aware openers (open / xdg-open / start) for a smoother cross-platform CLI experience.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/backups.ts` around lines 16 - 24, The "open" action is
macOS-specific (uses "open" and "Finder"); make it platform-aware by detecting
process.platform and selecting the correct opener for runtimePaths.backupRoot
(e.g., "open" for darwin, "xdg-open" for linux, and use "cmd" with
['/c','start',''] or an equivalent for win32) and update the action description
(replace "Finder" with a neutral phrase or per-platform wording). Modify the
commands branch that builds commands for action === "open" to use this platform
mapping (or call a small helper like getPlatformOpener()) so the commands array
and args are correct for each OS, and ensure destructive remains false.
src/core/doctor.ts (3)

152-162: SSH failure should be fail, not warn, when diagnostics cannot proceed.

If SSH connectivity fails, subsequent remote tool checks are skipped entirely, yet the check status is warn. This could mask a critical connectivity issue. Consider using fail status when SSH is unreachable, since the user cannot deploy without it.

Proposed change
   } catch (error) {
     addCheck({
       id: "ssh-reachability",
       label: "SSH reachability",
-      status: "warn",
+      status: "fail",
       message:
         error instanceof Error
           ? error.message
           : `Unable to verify SSH connectivity to ${config.remote.host}.`,
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/doctor.ts` around lines 152 - 162, The SSH connectivity catch block
in the doctor flow is marking the "ssh-reachability" check as "warn" which hides
a blocking error; update the addCheck invocation in the catch of the try that
verifies SSH (the block adding id "ssh-reachability") to set status to "fail"
instead of "warn" and keep the existing error message logic (error instanceof
Error ? error.message : `Unable to verify SSH connectivity to
${config.remote.host}.`) so the failure is clearly surfaced when remote tool
checks cannot proceed.

139-151: Minor duplication: remote tools list duplicates logic from requiredTools.

The database-specific tools (mysqldump, mysql, pg_dump, psql) are listed both in requiredTools() (lines 20-25) and again here. Consider extracting a shared helper to avoid drift.

Possible extraction
function databaseTools(engine: "mysql" | "postgres"): string[] {
  return engine === "mysql" ? ["mysql", "mysqldump"] : ["psql", "pg_dump"];
}

Then use in both requiredTools and the remote tool check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/doctor.ts` around lines 139 - 151, Extract the duplicated
DB-specific tool list into a shared helper (e.g., databaseTools(engine: "mysql"
| "postgres"): string[]) and use it both in requiredTools() and in the
remote-tool check where remoteTools is currently computed; replace the inline
ternary that builds remoteTools with a call to
databaseTools(config.database.engine), and update requiredTools() to call the
same helper instead of hardcoding ["mysqldump","mysql"] or ["pg_dump","psql"];
keep existing calls to remoteToolExists(runner, config, tool) and addCheck({
id:`remote-tool-${tool}`, ... }) unchanged.

29-59: Consider adding a note about potential hangs if remote command stalls.

The SSH ConnectTimeout=2 handles connection timeouts, but if the connection succeeds and the remote command -v hangs (e.g., due to shell initialization issues), there's no read timeout. This is low-risk since command -v is fast, but worth documenting or handling in a follow-up for robustness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/doctor.ts` around lines 29 - 59, The remoteToolExists function may
hang if SSH connects but the remote command stalls; update remoteToolExists to
guard against read stalls by adding a per-run timeout when calling runner.run
(or wrapping runner.run in a timeout Promise) and handle timeout by returning
false and optionally logging; reference the remoteToolExists function, the
runner: ProcessRunner instance and the runner.run invocation to add a timeout
option or a Promise.race-based cancel, and also add a short comment above
remoteToolExists documenting the potential stall and the chosen timeout
behavior.
src/tasks/composer.ts (1)

75-75: Redundant mkdir call — createBackupSession already creates the directory.

Per the context snippet, createBackupSession already calls await mkdir(backupDir, { recursive: true }) before returning. This line is unnecessary.

Remove redundant mkdir
   const session = await createBackupSession(runtime.options.cwd, runtime.config);
-  await mkdir(session.backupDir, { recursive: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/composer.ts` at line 75, The call to mkdir is redundant because
createBackupSession already creates the directory; remove the unnecessary await
mkdir(session.backupDir, { recursive: true }) from the compose/backup flow (the
code that runs after createBackupSession returns) so you rely on
createBackupSession's directory creation—locate references to session.backupDir
and the createBackupSession function to remove this duplicate mkdir invocation.
src/tasks/database.ts (2)

308-316: Complex string concatenation for remote clear command is fragile.

This constructs the clear command by concatenating .command and manually quoting .args. This bypasses the structured CommandSpec design and could break if clearSpec format changes. Consider a helper to serialize a CommandSpec to a shell string.

Extract a helper function
function specToShellCommand(spec: CommandSpec): string {
  if (spec.shell) return spec.command;
  return [spec.command, ...(spec.args ?? []).map(shellQuote)].join(" ");
}

Then use:

sshCommand(
  runtime,
  specToShellCommand(clearSpec(remoteCreds.engine, remoteCreds, "clear remote database")),
  remoteCreds.engine === "mysql" ? mysqlEnv(remoteCreds.password) : postgresEnv(remoteCreds.password),
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/database.ts` around lines 308 - 316, The current call builds the
remote clear command by manually concatenating clearSpec(...).command and quoted
args, which is fragile; add a helper function (e.g., specToShellCommand(spec:
CommandSpec)) that returns spec.command directly when spec.shell is true
otherwise joins spec.command with (spec.args ?? []).map(shellQuote), then
replace the inline concat in the sshCommand invocation with
specToShellCommand(clearSpec(remoteCreds.engine, remoteCreds, "clear remote
database")); keep the same env selection (mysqlEnv/postgresEnv) and reuse
existing symbols sshCommand, clearSpec, shellQuote, mysqlEnv, postgresEnv to
minimize changes.

133-150: Missing display property on SSH command spec.

Unlike other command builders (e.g., rsyncCopyFromRemote), sshCommand doesn't set display. This may result in less informative progress output or error messages showing raw SSH command strings instead of human-readable labels.

Add display parameter
-function sshCommand(runtime: LoadedRuntime, remoteScript: string, env?: Record<string, string>): CommandSpec {
+function sshCommand(runtime: LoadedRuntime, remoteScript: string, env?: Record<string, string>, display?: string): CommandSpec {
   const prefix = env
     ? `${Object.entries(env)
         .map(([key, value]) => `${key}=${shellQuote(value ?? "")}`)
         .join(" ")} `
     : "";
   return {
     command: "ssh",
     args: [
       "-p",
       String(runtime.config.remote.port),
       `${runtime.config.remote.user}@${runtime.config.remote.host}`,
       `${prefix}${remoteScript}`,
     ],
     stdout: "pipe",
     stderr: "pipe",
+    display,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/database.ts` around lines 133 - 150, The sshCommand function
returns a CommandSpec without a human-readable display label; update sshCommand
(the function returning CommandSpec) to include a display property similar to
other builders (e.g., rsyncCopyFromRemote) so progress/errors show a friendly
label instead of the raw SSH string—add a display array or string that includes
"ssh", runtime.config.remote.host (or user@host) and a short remoteScript
identifier so callers/logging display meaningful information.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.gitignore:
- Around line 15-16: The gitignore entries "_.log" and
"report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json" are using underscores instead of the
wildcard "*"; replace "_.log" with "*.log" and change the report pattern to a
proper glob (for example "report.[0-9]*_[0-9]*_[0-9]*_[0-9]*.json" if the parts
are underscore-separated numbers, or "report.*.*.*.*.json" /
"report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json" depending on the actual filename
format) so the intended files are matched.

In `@src/cli/commands/init.ts`:
- Around line 12-14: The init command currently ignores the parsed --force flag
and may overwrite an existing tug.toml; update the logic around optionsFromArgs
and writeInitialConfig so the force behavior is respected: either pass
options.force into writeInitialConfig (modify its signature to accept a force
boolean) or, before calling writeInitialConfig, resolve the target path from
options.cwd/options.configPath and if options.force is false check for file
existence and abort with a clear message instead of overwriting; reference the
functions optionsFromArgs, writeInitialConfig and the options properties
options.cwd, options.configPath, options.force when implementing this guard or
signature change.

In `@src/cli/main.ts`:
- Around line 31-33: Replace the current rawArgs scanning logic that sets
hasSubcommand (using context.rawArgs.some(...)) with a check against
context.args.command: determine if context.args.command exists and is not "tug"
(or is one of the known subcommands: init, doctor, folder, db, composer, shell,
backups); if so treat it as a subcommand and return early. Update the code paths
that reference hasSubcommand to use this new check (look for symbols
context.rawArgs, context.args.command and the existing early-return) so option
values like "--config tug.toml" no longer trigger a false positive.

In `@src/core/config.ts`:
- Around line 84-91: The current try/catch around accessing and copying
exampleEnvSource (.env.example) to exampleEnvTarget (.env.sample) swallows all
errors and uses an unusual target name; modify the catch to only ignore a
missing-source error by checking the error.code === 'ENOENT' (rethrow or surface
any other errors) and rename exampleEnvTarget to a conventional '.env' (or
confirm intent if '.env.sample' was deliberate) so update the variable
exampleEnvTarget and the copyFile call accordingly; keep the access/copyFile
calls and only suppress the specific ENOENT case.

In `@src/core/env.ts`:
- Around line 26-42: The parseDatabaseUrl function currently calls new
URL(urlString) without handling malformed input; wrap the URL construction in a
try-catch inside parseDatabaseUrl, catch the TypeError (or any error), and
rethrow a new Error with a clear, user-friendly message (e.g., "Invalid
DATABASE_URL: <original message>") while preserving original error details; keep
the rest of parseDatabaseUrl logic (normalizeEngine,
host/port/database/user/password/url) unchanged and ensure the thrown error is
descriptive for callers expecting DatabaseCredentials.
- Around line 107-124: fetchRemoteEnv currently ignores the SSH exit status and
injects raw path text into the remote command; update fetchRemoteEnv to validate
the runner.run result (check result.exitCode or equivalent) and throw a
descriptive error including result.stderr when SSH fails, and defensively
quote/escape the remote env path (config.database.remote_env_path ||
config.remote.env_path) before embedding it in the SSH command string so shell
metacharacters / spaces can't break the remote cat invocation; reference
fetchRemoteEnv, runner.run, result, dotenv.parse and the remote_env_path symbol
when making the changes.

In `@src/core/process.ts`:
- Around line 38-40: The code constructs the shell command array using a
hardcoded "/bin/zsh" when spec.shell is true; replace that with a
configurable/fallback shell by using process.env.SHELL (or another config) with
a fallback like "/bin/sh" so the command array becomes [chosenShell, "-lc",
spec.command]; update the logic that builds command (the const command variable
where spec.shell is checked) to use this selectedShell value (e.g., const shell
= process.env.SHELL ?? "/bin/sh") to ensure portability for functions that rely
on shell pipes (spec.shell true).

In `@src/tasks/composer.ts`:
- Around line 72-92: plan() currently calls createBackupSession and mkdir before
ensureForceForDestructive, causing duplicate sessions and orphan dirs; remove
the createBackupSession and mkdir calls from plan(), change plan() to accept
either a backupDir argument or build commands without creating a session (e.g.,
call buildRemoteBackupCommands(runtime, backupDir) only when backupDir is
provided), and move a single createBackupSession + mkdir into run() before
ensureForceForDestructive so run() creates the session once and passes its
backupDir into plan()/buildRemoteBackupCommands; update run() to call
plan(runtime, session.backupDir) or otherwise build commands after session
creation to ensure only one session/dir is created.

In `@src/tasks/database.ts`:
- Around line 214-230: The toolsFor function returns incorrect tool lists for
pull actions: update the logic so that for action === "pull" you return only the
importer on the local side and only the exporter on the remote side (for engine
"mysql" local pull should return ["mysql"], remote pull ["mysqldump"]; for
Postgres local pull ["psql"], remote pull ["pg_dump"]). Keep the existing
behavior for non-pull actions (push/export) unchanged; modify the branches that
check engine, side, and action in toolsFor to reflect these mappings.
- Around line 95-113: The args in function postgresClearSpec inject credential
values directly (host, port, user, database) which can break with special
characters; update postgresClearSpec to wrap each interpolated credential value
using the same shellQuote helper used elsewhere (e.g.,
`shellQuote(credentials.host)`, `shellQuote(credentials.port)`,
`shellQuote(credentials.user)`, `shellQuote(credentials.database)`) so the
generated args are safely quoted while preserving the existing flags, env via
postgresEnv, and display label.
- Around line 255-269: The backup session is created too early in
buildDatabaseCommands (createBackupSession) which can leave orphan directories
if assertDatabasePrereqs fails; reorder the steps so you first resolve
credentials (resolveLocalDatabaseCredentials, resolveRemoteDatabaseCredentials)
and call assertDatabasePrereqs(runtime, localCreds.engine, remoteCreds.engine,
action) before calling createBackupSession, then assign session and continue;
ensure any subsequent use of session remains valid after this reordering and
adjust variable declarations if needed.

In `@src/tasks/folder.ts`:
- Around line 39-46: The buildFolderPullCommands function emits real mkdir -p
commands even during a dry run; update buildFolderPullCommands to check
runtime.options.dryRun (or equivalent flag on runtime.options) and when true do
not produce real mkdirCommands for runtime.config.files.pull — either omit those
entries entirely or replace them with non-mutating no-op commands (e.g., an
explicit dry-run echo or a skipped marker) so folder pull --dry-run does not
modify the local filesystem; adjust references to mkdirCommands in
buildFolderPullCommands accordingly.
- Around line 28-31: The rsync/ssh argument strings interpolate
runtime.config.remote.app_path and folder directly, which can break or allow
shell injection on the remote; import the existing shellQuote utility (from
"../core/shell") and wrap the posix-joined remote path and the target folder
values used in the rsync/rsync-path and destination arguments (the expressions
using path.posix.join(runtime.config.remote.app_path, folder) and folder) with
shellQuote so they are single-quoted and properly escaped before interpolating
into the rsync/ssh command strings.

In `@src/tasks/shell.ts`:
- Around line 12-13: The template string that builds the remote command (`cd
${runtime.config.remote.app_path} && exec $SHELL -l`) injects raw
runtime.config.remote.app_path into a shell expression; change this to properly
quote and escape the path before embedding (e.g., use a shell-escape helper or
wrap the path in single quotes and escape any internal single quotes using the
standard '\'' pattern) so paths with spaces or metacharacters are safe, and
replace the inline interpolation in the command array with the escaped/quoted
value.

In `@src/tui/app.ts`:
- Around line 277-283: The terminal-task branch tears down the UI with
renderer.destroy() then calls await task.execute(runtime) outside the
surrounding try/catch, so SSH/session failures become unhandled; wrap the
execution and result handling for the "terminal" task in the same error handling
as the rest of the function (or add a local try/catch around await
task.execute(runtime)) to catch exceptions, log a clear error (including
error.message or stack), and set process.exitCode to a non-zero value on
failure; ensure you still call renderer.destroy() before running the task and
that successful completion sets process.exitCode = 0 and returns as before.

In `@tests/process.test.ts`:
- Around line 5-19: The test "merges custom env with PATH for spawned commands"
currently never verifies the child process received TUG_TEST_ENV; update the
test in tests/process.test.ts that uses BunProcessRunner.run so the spawned
command prints its environment (e.g., run a shell/command that echoes
$TUG_TEST_ENV or runs env and outputs that variable) and then add an assertion
that result.stdout contains the expected TUG_TEST_ENV value (e.g., "1"); keep
the intent and test name aligned by asserting the environment variable is
present in the child process output.

---

Nitpick comments:
In `@package.json`:
- Line 26: The package.json entry for the devDependency "@types/bun" uses the
floating tag "latest", which makes installs non-reproducible; replace it with an
explicit semver range or pinned version (e.g., "^1.3.11" or "1.3.11") by
updating the "@types/bun" value in package.json or run the command `bun add -d
`@types/bun`` to have Bun write a consistent range into package.json and the
lockfile; ensure you commit the updated package.json and lockfile so CI and
local installs are deterministic.

In `@src/core/doctor.ts`:
- Around line 152-162: The SSH connectivity catch block in the doctor flow is
marking the "ssh-reachability" check as "warn" which hides a blocking error;
update the addCheck invocation in the catch of the try that verifies SSH (the
block adding id "ssh-reachability") to set status to "fail" instead of "warn"
and keep the existing error message logic (error instanceof Error ?
error.message : `Unable to verify SSH connectivity to ${config.remote.host}.`)
so the failure is clearly surfaced when remote tool checks cannot proceed.
- Around line 139-151: Extract the duplicated DB-specific tool list into a
shared helper (e.g., databaseTools(engine: "mysql" | "postgres"): string[]) and
use it both in requiredTools() and in the remote-tool check where remoteTools is
currently computed; replace the inline ternary that builds remoteTools with a
call to databaseTools(config.database.engine), and update requiredTools() to
call the same helper instead of hardcoding ["mysqldump","mysql"] or
["pg_dump","psql"]; keep existing calls to remoteToolExists(runner, config,
tool) and addCheck({ id:`remote-tool-${tool}`, ... }) unchanged.
- Around line 29-59: The remoteToolExists function may hang if SSH connects but
the remote command stalls; update remoteToolExists to guard against read stalls
by adding a per-run timeout when calling runner.run (or wrapping runner.run in a
timeout Promise) and handle timeout by returning false and optionally logging;
reference the remoteToolExists function, the runner: ProcessRunner instance and
the runner.run invocation to add a timeout option or a Promise.race-based
cancel, and also add a short comment above remoteToolExists documenting the
potential stall and the chosen timeout behavior.

In `@src/core/shell.ts`:
- Around line 5-6: The joinCommand function currently concatenates parts with
spaces which can misrepresent args containing spaces or shell-sensitive
characters; update joinCommand to map each element through shellQuote (use the
existing shellQuote function) and then join the quoted parts with a single space
so every argument is safely and unambiguously rendered (also ensure the function
still accepts string[] and handles empty strings by quoting them via
shellQuote).

In `@src/tasks/backups.ts`:
- Around line 16-24: The "open" action is macOS-specific (uses "open" and
"Finder"); make it platform-aware by detecting process.platform and selecting
the correct opener for runtimePaths.backupRoot (e.g., "open" for darwin,
"xdg-open" for linux, and use "cmd" with ['/c','start',''] or an equivalent for
win32) and update the action description (replace "Finder" with a neutral phrase
or per-platform wording). Modify the commands branch that builds commands for
action === "open" to use this platform mapping (or call a small helper like
getPlatformOpener()) so the commands array and args are correct for each OS, and
ensure destructive remains false.

In `@src/tasks/composer.ts`:
- Line 75: The call to mkdir is redundant because createBackupSession already
creates the directory; remove the unnecessary await mkdir(session.backupDir, {
recursive: true }) from the compose/backup flow (the code that runs after
createBackupSession returns) so you rely on createBackupSession's directory
creation—locate references to session.backupDir and the createBackupSession
function to remove this duplicate mkdir invocation.

In `@src/tasks/database.ts`:
- Around line 308-316: The current call builds the remote clear command by
manually concatenating clearSpec(...).command and quoted args, which is fragile;
add a helper function (e.g., specToShellCommand(spec: CommandSpec)) that returns
spec.command directly when spec.shell is true otherwise joins spec.command with
(spec.args ?? []).map(shellQuote), then replace the inline concat in the
sshCommand invocation with specToShellCommand(clearSpec(remoteCreds.engine,
remoteCreds, "clear remote database")); keep the same env selection
(mysqlEnv/postgresEnv) and reuse existing symbols sshCommand, clearSpec,
shellQuote, mysqlEnv, postgresEnv to minimize changes.
- Around line 133-150: The sshCommand function returns a CommandSpec without a
human-readable display label; update sshCommand (the function returning
CommandSpec) to include a display property similar to other builders (e.g.,
rsyncCopyFromRemote) so progress/errors show a friendly label instead of the raw
SSH string—add a display array or string that includes "ssh",
runtime.config.remote.host (or user@host) and a short remoteScript identifier so
callers/logging display meaningful information.

In `@src/tasks/index.ts`:
- Around line 3-54: The taskDescriptors array is only metadata while dispatch
logic in src/tui/app.ts branches on descriptor.id, risking drift; modify the
TaskDescriptor shape to include execution routing (e.g., add an executor and
optional confirmHandler fields) and update taskDescriptors entries (folderPull,
folderPush, databasePull, databasePush, composerPull, composerPush, backups,
terminal) to reference the appropriate handler names; then change the dispatcher
in src/tui/app.ts to call descriptor.executor(descriptor) (and
descriptor.confirmHandler when needed) so descriptor.id and action stay
colocated with their execution functions.

In `@tests/config.test.ts`:
- Around line 21-26: The test currently writes ".env.example" and calls
writeInitialConfig but doesn't assert that writeInitialConfig copied it to
".env.sample"; update the test (around where writeProjectFile,
writeInitialConfig, and readConfig are used) to assert that a ".env.sample" file
exists in the test cwd and that its contents equal the expected
"DB_DATABASE=test\nDB_USER=root\n" (or match the contents of the written
.env.example) so regressions in the copy behavior are caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 803f8847-a9cb-49d1-8239-54a7f49e28b7

📥 Commits

Reviewing files that changed from the base of the PR and between d054032 and a8ea341.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (43)
  • .env.example
  • .gitignore
  • README.md
  • oxlint.json
  • package.json
  • src/cli/commands/backups.ts
  • src/cli/commands/composer.ts
  • src/cli/commands/db.ts
  • src/cli/commands/doctor.ts
  • src/cli/commands/folder.ts
  • src/cli/commands/init.ts
  • src/cli/commands/shell.ts
  • src/cli/common.ts
  • src/cli/main.ts
  • src/core/config.ts
  • src/core/doctor.ts
  • src/core/env.ts
  • src/core/logging.ts
  • src/core/paths.ts
  • src/core/process.ts
  • src/core/runtime.ts
  • src/core/shell.ts
  • src/tasks/backups.ts
  • src/tasks/composer.ts
  • src/tasks/database.ts
  • src/tasks/folder.ts
  • src/tasks/index.ts
  • src/tasks/shared.ts
  • src/tasks/shell.ts
  • src/tui/app.ts
  • src/tui/components/confirmation-dialog.ts
  • src/tui/screens/dashboard.ts
  • src/tui/screens/task-runner.ts
  • src/types/index.ts
  • tests/config.test.ts
  • tests/database.test.ts
  • tests/doctor.test.ts
  • tests/env.test.ts
  • tests/folder.test.ts
  • tests/helpers.ts
  • tests/process.test.ts
  • tsconfig.json
  • tug.example.toml

Comment thread .gitignore Outdated
Comment thread src/cli/commands/init.ts
Comment thread src/cli/main.ts Outdated
Comment thread src/core/config.ts
Comment thread src/core/env.ts
Comment thread src/tasks/folder.ts Outdated
Comment thread src/tasks/folder.ts
Comment thread src/tasks/shell.ts Outdated
Comment thread src/tui/app.ts
Comment thread tests/process.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/tasks/composer.ts (1)

106-122: ⚠️ Potential issue | 🟡 Minor

Move createBackupSession() behind the remaining preflight checks.

This still creates a backup directory before plan() verifies composer is enabled and before the push path checks for composer.json. If either check fails, the command leaves an orphan session directory behind.

Suggested change
 export async function run(runtime: LoadedRuntime, action: ComposerAction): Promise<TaskResult> {
   if (!runtime.options.force) {
     throw new Error("Composer sync is destructive. Re-run with --force or use the interactive TUI.");
   }
-  const session = await createBackupSession(runtime.options.cwd, runtime.config);
-  const taskPlan = await plan(runtime, action, session.backupDir);
-  ensureForceForDestructive(runtime, taskPlan);
-  if (action === "pull") {
-    await backupLocalComposerFiles(runtime, session.backupDir);
-  } else {
+  await plan(runtime, action);
+  if (action === "push") {
     const localComposer = path.resolve(runtime.options.cwd, "composer.json");
     if (!(await fileExists(localComposer))) {
       throw new Error("composer.json is required for composer push.");
     }
   }
+  const session = await createBackupSession(runtime.options.cwd, runtime.config);
+  const taskPlan = await plan(runtime, action, session.backupDir);
+  ensureForceForDestructive(runtime, taskPlan);
+  if (action === "pull") {
+    await backupLocalComposerFiles(runtime, session.backupDir);
+  }
   await executePlan(runtime, taskPlan);
   return ok(`${taskPlan.title} finished successfully.`, [session.backupDir]);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/composer.ts` around lines 106 - 122, The code calls
createBackupSession(...) too early in run(): move the createBackupSession call
to after the preflight checks (after plan(...) and after verifying push-path
composer.json existence) so the backup directory is only created when all
validations pass; specifically, keep the existing calls to plan(runtime, action,
session.backupDir) and ensureForceForDestructive(runtime, taskPlan) but first
invoke plan(runtime, action) or adjust plan usage so preflight checks (composer
enabled and fileExists(localComposer) for push) run before creating the session;
then call createBackupSession(...) and pass session.backupDir into
backupLocalComposerFiles(...) and executePlan(...).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/core/config.ts`:
- Around line 65-71: The configExists function incorrectly treats all access()
errors as “missing” config; update configExists(cwd: string, explicitPath?:
string) to call access(resolveConfigPath(...)) inside try/catch but only return
false when the caught error.code === 'ENOENT' and re-throw any other errors so
permission or transient I/O errors surface; reference the existing
resolveConfigPath and access calls and ensure non-ENOENT errors are not
swallowed.

In `@src/core/doctor.ts`:
- Around line 38-63: The timeout created inside the Promise.race for the SSH
probe is never cleared, leaving a live timer after runner.run completes; modify
the race so the timeout handle is stored (e.g., let timer = setTimeout(...))
and, when the ssh promise (runner.run) resolves or rejects, call
clearTimeout(timer) before returning; update the anonymous timeout Promise and
the logic that handles the runner.run result (the Promise.race usage around
runner.run, the setTimeout resolver, and the resolved object with exitCode 124)
to ensure the timer is cleared in all code paths that finish the probe.

In `@src/tasks/database.ts`:
- Around line 194-220: remoteCommandExists can hang after SSH connects because
ConnectTimeout only limits connection setup; add a hard overall timeout to the
probe (same bounded-probe pattern used by the doctor code) so
assertDatabasePrereqs()/database.doctor() can't block indefinitely. Modify the
runtime.runner.run call inside remoteCommandExists to include the runner's
timeout option (or wrap the call in a Promise.race that rejects after a fixed
timeout, e.g. 3–5s) and ensure the runner process is aborted/cancelled on
timeout; keep the probe behavior and return semantics (resolve true when
exitCode === 0, false otherwise) unchanged.
- Around line 160-183: The rsync helpers rsyncCopyFromRemote and
rsyncCopyToRemote embed remote operands unquoted, which breaks when
runtime.config.remote.* paths contain spaces or metacharacters; fix by quoting
the remote path portion in the args so rsync receives user@host:"path" (e.g.
construct
`${runtime.config.remote.user}@${runtime.config.remote.host}:"${from}"` for the
remote source and
`${runtime.config.remote.user}@${runtime.config.remote.host}:"${to}"` for the
remote destination), ensuring both functions wrap the remote path in quotes (or
use a proper shell-escape utility) so remote.app_path is safe.

---

Duplicate comments:
In `@src/tasks/composer.ts`:
- Around line 106-122: The code calls createBackupSession(...) too early in
run(): move the createBackupSession call to after the preflight checks (after
plan(...) and after verifying push-path composer.json existence) so the backup
directory is only created when all validations pass; specifically, keep the
existing calls to plan(runtime, action, session.backupDir) and
ensureForceForDestructive(runtime, taskPlan) but first invoke plan(runtime,
action) or adjust plan usage so preflight checks (composer enabled and
fileExists(localComposer) for push) run before creating the session; then call
createBackupSession(...) and pass session.backupDir into
backupLocalComposerFiles(...) and executePlan(...).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 334574a5-09f9-4206-8493-45f900a9567c

📥 Commits

Reviewing files that changed from the base of the PR and between a8ea341 and 1472493.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • .gitignore
  • package.json
  • src/cli/commands/init.ts
  • src/cli/main.ts
  • src/core/config.ts
  • src/core/doctor.ts
  • src/core/env.ts
  • src/core/process.ts
  • src/core/shell.ts
  • src/tasks/backups.ts
  • src/tasks/composer.ts
  • src/tasks/database.ts
  • src/tasks/folder.ts
  • src/tasks/shell.ts
  • src/tui/app.ts
  • tests/config.test.ts
  • tests/database.test.ts
  • tests/folder.test.ts
  • tests/process.test.ts
✅ Files skipped from review due to trivial changes (5)
  • tests/process.test.ts
  • src/core/shell.ts
  • .gitignore
  • tests/database.test.ts
  • tests/folder.test.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • package.json
  • src/cli/commands/init.ts
  • src/tasks/backups.ts
  • src/cli/main.ts
  • src/core/env.ts
  • src/tasks/folder.ts
  • src/core/process.ts

Comment thread src/core/config.ts
Comment on lines +65 to +71
export async function configExists(cwd: string, explicitPath?: string): Promise<boolean> {
try {
await access(resolveConfigPath(cwd, explicitPath));
return true;
} catch {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Only treat ENOENT as “config missing.”

configExists() currently returns false for any access() failure, so permission errors and transient I/O errors get misreported as “Missing tug.toml.” Re-throw non-ENOENT failures here so callers surface the real problem instead of a false missing-file result.

Suggested change
 export async function configExists(cwd: string, explicitPath?: string): Promise<boolean> {
   try {
     await access(resolveConfigPath(cwd, explicitPath));
     return true;
-  } catch {
-    return false;
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
+      return false;
+    }
+    throw error;
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function configExists(cwd: string, explicitPath?: string): Promise<boolean> {
try {
await access(resolveConfigPath(cwd, explicitPath));
return true;
} catch {
return false;
}
export async function configExists(cwd: string, explicitPath?: string): Promise<boolean> {
try {
await access(resolveConfigPath(cwd, explicitPath));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
return false;
}
throw error;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/config.ts` around lines 65 - 71, The configExists function
incorrectly treats all access() errors as “missing” config; update
configExists(cwd: string, explicitPath?: string) to call
access(resolveConfigPath(...)) inside try/catch but only return false when the
caught error.code === 'ENOENT' and re-throw any other errors so permission or
transient I/O errors surface; reference the existing resolveConfigPath and
access calls and ensure non-ENOENT errors are not swallowed.

Comment thread src/core/doctor.ts
Comment on lines +38 to +63
const result = await Promise.race([
runner.run({
command: "ssh",
args: [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=2",
"-o",
"NumberOfPasswordPrompts=0",
"-o",
"PreferredAuthentications=publickey",
"-p",
String(config.remote.port),
`${config.remote.user}@${config.remote.host}`,
`command -v ${tool} >/dev/null 2>&1`,
],
stdout: "pipe",
stderr: "pipe",
allowFailure: true,
}),
new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve) => {
setTimeout(() => {
resolve({ exitCode: 124, stdout: "", stderr: "Timed out checking remote tool." });
}, 3000);
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear the timeout when the SSH probe finishes.

The timer created in this Promise.race() is never cancelled, so even a fast ssh result leaves a live 3-second handle behind. In CLI mode that can delay process exit and make tug doctor look hung after the checks have already completed.

Suggested change
 async function remoteToolExists(
   runner: ProcessRunner,
   config: TugConfig,
   tool: string,
 ): Promise<boolean> {
+  let timeoutId: ReturnType<typeof setTimeout> | undefined;
   try {
+    const timeout = new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve) => {
+      timeoutId = setTimeout(() => {
+        resolve({ exitCode: 124, stdout: "", stderr: "Timed out checking remote tool." });
+      }, 3000);
+    });
     const result = await Promise.race([
       runner.run({
         command: "ssh",
         args: [
           "-o",
@@
         stderr: "pipe",
         allowFailure: true,
       }),
-      new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve) => {
-        setTimeout(() => {
-          resolve({ exitCode: 124, stdout: "", stderr: "Timed out checking remote tool." });
-        }, 3000);
-      }),
+      timeout,
     ]);
     return result.exitCode === 0;
   } catch {
     return false;
+  } finally {
+    if (timeoutId) {
+      clearTimeout(timeoutId);
+    }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/doctor.ts` around lines 38 - 63, The timeout created inside the
Promise.race for the SSH probe is never cleared, leaving a live timer after
runner.run completes; modify the race so the timeout handle is stored (e.g., let
timer = setTimeout(...)) and, when the ssh promise (runner.run) resolves or
rejects, call clearTimeout(timer) before returning; update the anonymous timeout
Promise and the logic that handles the runner.run result (the Promise.race usage
around runner.run, the setTimeout resolver, and the resolved object with
exitCode 124) to ensure the timer is cleared in all code paths that finish the
probe.

Comment thread src/tasks/database.ts
Comment on lines +160 to +183
function rsyncCopyFromRemote(runtime: LoadedRuntime, from: string, to: string, label: string): CommandSpec {
return {
command: "rsync",
args: [
"--archive",
`--rsh=ssh -p ${runtime.config.remote.port}`,
`${runtime.config.remote.user}@${runtime.config.remote.host}:${from}`,
to,
],
stdout: "pipe",
stderr: "pipe",
display: label,
};
}

function rsyncCopyToRemote(runtime: LoadedRuntime, from: string, to: string, label: string): CommandSpec {
return {
command: "rsync",
args: [
"--archive",
`--rsh=ssh -p ${runtime.config.remote.port}`,
from,
`${runtime.config.remote.user}@${runtime.config.remote.host}:${to}`,
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Quote the remote rsync path operands.

These helpers embed remote paths directly into the user@host:path rsync argument. Unlike src/tasks/composer.ts, the path is not shell-escaped here, so any remote.app_path with spaces or shell metacharacters will break pull/push database transfers.

Suggested change
 function rsyncCopyFromRemote(runtime: LoadedRuntime, from: string, to: string, label: string): CommandSpec {
   return {
     command: "rsync",
     args: [
       "--archive",
       `--rsh=ssh -p ${runtime.config.remote.port}`,
-      `${runtime.config.remote.user}@${runtime.config.remote.host}:${from}`,
+      `${runtime.config.remote.user}@${runtime.config.remote.host}:${shellQuote(from)}`,
       to,
     ],
@@
 function rsyncCopyToRemote(runtime: LoadedRuntime, from: string, to: string, label: string): CommandSpec {
   return {
     command: "rsync",
     args: [
       "--archive",
       `--rsh=ssh -p ${runtime.config.remote.port}`,
       from,
-      `${runtime.config.remote.user}@${runtime.config.remote.host}:${to}`,
+      `${runtime.config.remote.user}@${runtime.config.remote.host}:${shellQuote(to)}`,
     ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/database.ts` around lines 160 - 183, The rsync helpers
rsyncCopyFromRemote and rsyncCopyToRemote embed remote operands unquoted, which
breaks when runtime.config.remote.* paths contain spaces or metacharacters; fix
by quoting the remote path portion in the args so rsync receives
user@host:"path" (e.g. construct
`${runtime.config.remote.user}@${runtime.config.remote.host}:"${from}"` for the
remote source and
`${runtime.config.remote.user}@${runtime.config.remote.host}:"${to}"` for the
remote destination), ensuring both functions wrap the remote path in quotes (or
use a proper shell-escape utility) so remote.app_path is safe.

Comment thread src/tasks/database.ts
Comment on lines +194 to +220
async function remoteCommandExists(
runtime: LoadedRuntime,
tool: string,
): Promise<boolean> {
const result = await runtime.runner.run({
command: "ssh",
args: [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=2",
"-o",
"NumberOfPasswordPrompts=0",
"-o",
"PreferredAuthentications=publickey",
"-p",
String(runtime.config.remote.port),
`${runtime.config.remote.user}@${runtime.config.remote.host}`,
`command -v ${tool} >/dev/null 2>&1`,
],
stdout: "pipe",
stderr: "pipe",
allowFailure: true,
display: `check remote tool ${tool}`,
});
return result.exitCode === 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a hard timeout to the remote prerequisite probe.

ConnectTimeout=2 only bounds SSH connection setup. If the remote shell accepts the login and then stalls running command -v, both assertDatabasePrereqs() and database.doctor() can block indefinitely before any sync starts. This helper should use the same bounded probe pattern as src/core/doctor.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/database.ts` around lines 194 - 220, remoteCommandExists can hang
after SSH connects because ConnectTimeout only limits connection setup; add a
hard overall timeout to the probe (same bounded-probe pattern used by the doctor
code) so assertDatabasePrereqs()/database.doctor() can't block indefinitely.
Modify the runtime.runner.run call inside remoteCommandExists to include the
runner's timeout option (or wrap the call in a Promise.race that rejects after a
fixed timeout, e.g. 3–5s) and ensure the runner process is aborted/cancelled on
timeout; keep the probe behavior and return semantics (resolve true when
exitCode === 0, false otherwise) unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant