diff --git a/README.md b/README.md index 5d29480..75cc106 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,30 @@ mcp-1c --install "srv-1c\buh_prod" --server --db-user Admin --db-password pass > > Если версия платформы не определяется автоматически (нестандартный путь без номера версии), укажите её явно: `mcp-1c --install "путь" --platform "/custom/path/to/1cv8" --platform-version 8.3.13` +#### Бэкенды установщика + +Флаг `--installer` выбирает способ загрузки расширения в базу: + +- **`--installer=designer`** (по умолчанию) — пакетный режим `1cv8.exe DESIGNER /LoadConfigFromFiles`. Работает на платформах от 8.3.10. Минусы: 3-8 минут на установку, открывает GUI-окно 1С (на Windows Server может зависнуть в Session 0), ограниченная поддержка Linux 1С. +- **`--installer=ibcmd`** (быстро, headless, требует 8.3.18+) — утилита `ibcmd config import`. ~30 секунд, без GUI, нативная поддержка Linux 1С. + +```bash +# Файловая база — быстрая установка через ibcmd: +mcp-1c --install ~/Documents/InfoBase --installer=ibcmd + +# Клиент-серверная база — ibcmd подключается напрямую к СУБД, +# поэтому путь кластера 1С (cluster:1541\db) НЕ работает. +# Передавайте хост СУБД отдельно через --ibcmd-db-server: +mcp-1c --install "buh_prod" --server --installer=ibcmd \ + --ibcmd-db-server "pg-host port=6432" \ + --db-user dt_user --db-password secret + +# На загруженных production-базах с активными сеансами увеличьте таймаут: +mcp-1c --install ... --installer=ibcmd --ibcmd-timeout=300s +``` + +При lock contention (extension actively in use by rphost sessions) ibcmd выдаст понятную ошибку с тремя вариантами действий: подождать idle, увеличить `--ibcmd-timeout`, или откатиться на `--installer=designer`. + ### 3. Запустить HTTP-сервис 1С **Рекомендуемый способ** — стандартная публикация через Apache или IIS (Конфигуратор → Администрирование → Публикация на веб-сервере). Работает на Windows и Linux. Подробности — в [пошаговой инструкции](docs/getting-started.md#шаг-3-запустить-http-сервис-1с). @@ -165,8 +189,11 @@ mcp-1c --install "srv-1c\buh_prod" --server --db-user Admin --db-password pass | `--server` | — | — | Режим клиент-серверной базы: `--install` принимает строку подключения `сервер\база` (например `srv-1c\buh_prod`) | | `--platform` | — | — | Путь к бинарнику 1С (автоопределение, если не указан) | | `--platform-version` | — | — | Версия платформы 1С (например `8.3.13`). Определяется автоматически из пути к платформе. Укажите вручную, если платформа установлена в нестандартный путь без информации о версии. Минимальная поддерживаемая версия: 8.3.10 | -| `--db-user` | — | — | Пользователь базы 1С для DESIGNER (режим --install) | -| `--db-password` | — | — | Пароль базы 1С для DESIGNER (режим --install) | +| `--db-user` | — | — | Пользователь СУБД для DESIGNER/ibcmd (режим --install) | +| `--db-password` | — | — | Пароль СУБД для DESIGNER/ibcmd (режим --install) | +| `--installer` | — | `designer` | Бэкенд установщика: `designer` (по умолчанию, GUI-batch, 3-8 мин) или `ibcmd` (headless, ~30 сек, требует платформу 8.3.18+) | +| `--ibcmd-timeout` | — | `60s` | Таймаут одного вызова ibcmd (только с `--installer=ibcmd`). Увеличьте до 300s+ на загруженных production-базах с активными сеансами rphost. | +| `--ibcmd-db-server` | — | — | Хост СУБД для прямого подключения ibcmd (например `pg-host port=6432`). Требуется для `--installer=ibcmd --server`, потому что ibcmd обходит кластер 1С и подключается к PostgreSQL/MSSQL напрямую. Синтаксис пути кластера DESIGNER (`cluster:1541\db`) для ibcmd НЕ работает. | ## Логирование и вывод diff --git a/cmd/mcp-1c/main.go b/cmd/mcp-1c/main.go index 46fdacc..16025a1 100644 --- a/cmd/mcp-1c/main.go +++ b/cmd/mcp-1c/main.go @@ -47,6 +47,14 @@ func main() { platformVersion := flag.String("platform-version", "", "1C platform version override (e.g. 8.3.13), auto-detected from path if omitted") dbUser := flag.String("db-user", "", "1C database user for DESIGNER (install mode)") dbPassword := flag.String("db-password", "", "1C database password for DESIGNER (install mode)") + installerBackend := flag.String("installer", "designer", + "Installer backend: 'designer' (default, GUI-batch, 3-8 min) or 'ibcmd' (headless, ~30s, platform 8.3.18+)") + ibcmdTimeout := flag.Duration("ibcmd-timeout", 60*time.Second, + "Timeout per ibcmd invocation (only with --installer=ibcmd). Bump to 300s+ on busy bases with active sessions.") + ibcmdDBServer := flag.String("ibcmd-db-server", "", + "DBMS host for ibcmd direct connection (e.g. 'pg-host port=6432'). Required for --installer=ibcmd with --server, "+ + "because ibcmd bypasses 1C cluster and connects to PostgreSQL/MSSQL directly. "+ + "DESIGNER cluster path syntax (cluster:1541\\db) does NOT apply to ibcmd.") quiet := flag.Bool("quiet", false, "Suppress all stderr output even when running in a terminal. Takes precedence over --verbose. Also activated by env MCP_1C_NO_TTY=1.") verbose := flag.Bool("verbose", false, "Force verbose stderr output even when stdin is a pipe (useful for MCP client debugging). Overrides auto-detect and is itself overridden by --quiet.") // Sentinel 0 => "flag not passed", so the MCP_1C_MAX_RESPONSE_SIZE env var @@ -95,9 +103,27 @@ func main() { // Install mode. if *installDB != "" { - fmt.Println("Installing MCP extension into 1C database...") - if err := installer.Install(extension.Source, *installDB, *serverMode, *platformPath, *dbUser, *dbPassword, *platformVersion); err != nil { - fmt.Fprintf(os.Stderr, "Installation error: %v\n", err) + fmt.Printf("Installing MCP extension into 1C database (backend=%s)...\n", *installerBackend) + var installErr error + switch *installerBackend { + case "ibcmd": + installErr = installer.InstallViaIbcmd(extension.Source, installer.IbcmdOptions{ + DBPath: *installDB, + ServerMode: *serverMode, + PlatformExe: *platformPath, + DBUser: *dbUser, + DBPassword: *dbPassword, + PlatformVersion: *platformVersion, + Timeout: *ibcmdTimeout, + DBServer: *ibcmdDBServer, + }) + case "designer", "": + installErr = installer.Install(extension.Source, *installDB, *serverMode, *platformPath, *dbUser, *dbPassword, *platformVersion) + default: + installErr = fmt.Errorf("unknown installer backend %q (valid: 'designer' or 'ibcmd')", *installerBackend) + } + if installErr != nil { + fmt.Fprintf(os.Stderr, "Installation error: %v\n", installErr) os.Exit(1) } fmt.Println("Extension installed successfully.") diff --git a/installer/ibcmd.go b/installer/ibcmd.go new file mode 100644 index 0000000..edd355e --- /dev/null +++ b/installer/ibcmd.go @@ -0,0 +1,364 @@ +// Package installer — ibcmd backend for headless extension deployment. +// +// ibcmd is a CLI utility shipped with 1С:Enterprise platform since 8.3.18. +// Compared to DESIGNER batch mode it offers: +// - ~30 seconds per install vs 3-8 minutes (no GUI startup overhead) +// - No GUI window (no Session 0 issues on Windows Server) +// - Native Linux support (DESIGNER has limitations there) +// +// Gotchas codified here (from real-world deploys to cluster-managed PostgreSQL infobases): +// +// 1. --db-server="host port=6432" — NO trailing semicolon despite docs claim. +// With trailing ";", ibcmd misparses and falls back to default port 5432. +// +// 2. --user X requires --password Y paired. Else ibcmd jumps to interactive +// --request-db-pwd mode reading from STDIN. With redirected/closed STDIN +// this hangs forever (process visible, 0% CPU, no network). +// +// 3. --data dir must be cleaned between operations on shared file storage +// to avoid stale lock files. +// +// 4. config import files needs exclusive lock on infobase metadata. +// If extension is actively used by rphost sessions, ibcmd waits forever. +// We enforce a timeout (default 60s) with actionable error message. +package installer + +import ( + "context" + "embed" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + "unicode/utf8" + + "golang.org/x/text/encoding/charmap" +) + +// IbcmdOptions configures the ibcmd-based installer. +type IbcmdOptions struct { + // DBPath is the database location. In server mode: "server\database". + // In file mode: filesystem path. Used to derive --dbms / --db-server / --db-name. + DBPath string + + // ServerMode true => treat DBPath as "server\database" client-server connection. + // false => file-mode infobase. ibcmd uses different flags accordingly. + ServerMode bool + + // PlatformExe is the path to 1cv8.exe (or 1cv8c). Used to locate ibcmd + // next to it. If empty, FindPlatform() is called. + PlatformExe string + + // DBUser, DBPassword authenticate to the DBMS (PostgreSQL/MSSQL user). + // Required when ServerMode is true. Empty in file mode. + DBUser string + DBPassword string + + // PlatformVersion override (e.g. "8.3.27"). If empty, detected from PlatformExe. + PlatformVersion string + + // Timeout per ibcmd invocation. Default 60s. Increase if expecting lock + // contention with active rphost sessions. + Timeout time.Duration + + // DataDir is ibcmd's --data working directory. If empty, a temp dir is + // created and cleaned up. Useful to override for debugging. + DataDir string + + // DBMS — "PostgreSQL" or "MSSQLServer" or "IBMDB2" or "OracleDatabase". + // If empty in ServerMode, defaults to PostgreSQL (most common for 1С on Linux). + DBMS string + + // DBServer overrides the server part of DBPath. If both DBPath and DBServer + // are set, DBServer wins. Useful for clusterized infobases where DBPath is + // "ras-cluster:1541\db1" but actual PG host is different. + DBServer string +} + +// defaultIbcmdTimeout is the fallback per-call timeout for ibcmd. 60 seconds +// covers normal config import + apply on idle bases. For busy production bases +// with active rphost sessions, caller should bump this to 300+. +const defaultIbcmdTimeout = 60 * time.Second + +// InstallViaIbcmd deploys the embedded extension XML sources to the target +// infobase via headless ibcmd. +// +// Steps: +// 1. Extract embedded XML to temp dir +// 2. Patch XML format version for platform compat (shared with DESIGNER path) +// 3. Find ibcmd executable next to platform binary +// 4. Run `ibcmd config import files --extension=X --base-dir=tmp ...` +// 5. Run `ibcmd config apply --extension=X --dynamic=auto ...` +// 6. Cleanup +// +// Returns nil on success, descriptive error on any step failure. +// +//garble:ignore +func InstallViaIbcmd(srcFS embed.FS, opts IbcmdOptions) error { + if opts.Timeout == 0 { + opts.Timeout = defaultIbcmdTimeout + } + + // Resolve platform exe (shared logic with DESIGNER installer). + if opts.PlatformExe == "" { + exe, err := FindPlatform() + if err != nil { + return fmt.Errorf("finding 1C platform: %w", err) + } + opts.PlatformExe = exe + } + fmt.Printf("Platform: %s\n", opts.PlatformExe) + + // Find ibcmd next to platform exe. + ibcmdPath, err := findIbcmd(opts.PlatformExe) + if err != nil { + return fmt.Errorf("finding ibcmd: %w (requires platform 8.3.18+)", err) + } + fmt.Printf("ibcmd: %s\n", ibcmdPath) + + // Extract extension XML to temp dir (same as DESIGNER path). + extDir, err := os.MkdirTemp("", "mcp-1c-ext-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(extDir) + + if err := extractFS(srcFS, "src", extDir); err != nil { + return fmt.Errorf("extracting extension sources: %w", err) + } + + // Patch XML format version to match the target platform. + fmtVer := formatVersionForPlatform(opts.PlatformExe) + if err := patchFormatVersion(extDir, fmtVer); err != nil { + return fmt.Errorf("patching format version: %w", err) + } + + // Allocate ibcmd data dir. + dataDir := opts.DataDir + if dataDir == "" { + dataDir, err = os.MkdirTemp("", "mcp-1c-ibcmd-data-*") + if err != nil { + return fmt.Errorf("creating ibcmd data dir: %w", err) + } + defer os.RemoveAll(dataDir) + } + + // Build the connection args. These are shared by all ibcmd invocations. + connArgs, err := buildConnectionArgs(opts) + if err != nil { + return fmt.Errorf("building connection args: %w", err) + } + + // Step 1: import XML into config storage. + // + // Note: `ibcmd config import ` imports the whole directory. + // `ibcmd config import files` is for selective per-file imports + // (with positional ), not what we want here. + fmt.Println("Importing extension XML via ibcmd config import...") + importArgs := append([]string{ + "config", "import", + "--extension=" + extensionName, + "--data=" + dataDir, + }, connArgs...) + importArgs = append(importArgs, extDir) // positional arg + if err := runIbcmd(context.Background(), ibcmdPath, importArgs, opts.Timeout); err != nil { + return classifyIbcmdError("config import", err, opts.Timeout) + } + + // Step 2: apply config to DB. + fmt.Println("Applying extension config via ibcmd config apply --dynamic=auto...") + applyArgs := append([]string{ + "config", "apply", + "--extension=" + extensionName, + "--dynamic=auto", + "--data=" + dataDir, + }, connArgs...) + if err := runIbcmd(context.Background(), ibcmdPath, applyArgs, opts.Timeout); err != nil { + return classifyIbcmdError("config apply", err, opts.Timeout) + } + + fmt.Println("Extension installed successfully via ibcmd.") + return nil +} + +// findIbcmd looks for ibcmd executable next to the platform binary. +// On Windows: ibcmd.exe in the same directory. On Linux: ibcmd (no extension). +// +// If platformExe is something like "C:\Program Files\1cv8\8.3.27.1859\bin\1cv8.exe", +// we expect ibcmd at "C:\Program Files\1cv8\8.3.27.1859\bin\ibcmd.exe". +func findIbcmd(platformExe string) (string, error) { + binDir := filepath.Dir(platformExe) + name := "ibcmd" + if runtime.GOOS == "windows" { + name = "ibcmd.exe" + } + candidate := filepath.Join(binDir, name) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + // Fallback: search PATH. + if found, err := exec.LookPath(name); err == nil { + return found, nil + } + return "", fmt.Errorf("ibcmd not found next to %s nor in PATH", platformExe) +} + +// buildConnectionArgs builds the DBMS connection args for ibcmd. +// +// Key design point: ibcmd bypasses the 1C cluster and connects directly to +// the DBMS (PostgreSQL/MSSQL). For cluster-managed infobases, the DESIGNER +// cluster path "cluster:1541\db" does NOT map to ibcmd flags — the user must +// provide the actual DBMS host via DBServer (--ibcmd-db-server flag). +// +// CRITICAL gotcha: --db-server="host port=6432" — NO trailing semicolon despite +// docs claim. With ";", ibcmd silently uses default port 5432. +func buildConnectionArgs(opts IbcmdOptions) ([]string, error) { + args := []string{} + if !opts.ServerMode { + // File-mode infobase. + args = append(args, "--db-path="+opts.DBPath) + return args, nil + } + + // Client-server mode: ibcmd needs explicit DBMS server + DB name. + // + // Two acceptable inputs: + // 1) Explicit DBServer + DBPath as plain DB name (preferred for ibcmd) + // 2) DBPath in DESIGNER "server\db" format, DBServer unset — we split + // and use parts[0] as DBMS host. WARNING: this works only if user + // put the actual DBMS host in DBPath (not 1C cluster name). + dbServer := opts.DBServer + dbName := "" + if dbServer != "" && !strings.Contains(opts.DBPath, `\`) { + // Preferred path: DBServer explicit, DBPath is plain DB name. + dbName = opts.DBPath + } else { + // Legacy split: parse "ServerName\DBName" from DBPath. + parts := strings.SplitN(opts.DBPath, `\`, 2) + if len(parts) == 2 { + if dbServer == "" { + dbServer = parts[0] + } + dbName = parts[1] + } else { + return nil, fmt.Errorf( + "--installer=ibcmd with --server requires either "+ + "(a) --ibcmd-db-server and --install , OR "+ + "(b) --install \\. Got DBPath=%q, DBServer=%q. "+ + "Note: ibcmd connects directly to DBMS, NOT through 1C cluster — "+ + "the DESIGNER cluster path syntax (cluster:1541\\db) does NOT work for ibcmd", + opts.DBPath, opts.DBServer, + ) + } + } + + dbms := opts.DBMS + if dbms == "" { + dbms = "PostgreSQL" + } + args = append(args, + "--dbms="+dbms, + // IMPORTANT: no trailing ";" after port=N (docs are wrong) + "--db-server="+dbServer, + "--db-name="+dbName, + ) + + // IMPORTANT: --user/--password MUST be paired. Single --user without + // --password causes ibcmd to enter interactive STDIN read mode and hang. + if opts.DBUser != "" { + if opts.DBPassword == "" { + return nil, errors.New("DBUser set but DBPassword empty — would hang on STDIN") + } + args = append(args, "--db-user="+opts.DBUser, "--db-pwd="+opts.DBPassword) + } + + return args, nil +} + +// runIbcmd executes ibcmd with the given args and a hard timeout. +// +// Closes stdin explicitly to prevent ibcmd's interactive password prompt +// from waiting forever when it can't parse argv credentials. +func runIbcmd(parent context.Context, ibcmdPath string, args []string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, ibcmdPath, args...) + // Explicitly nil stdin — ibcmd reads STDIN only when interactive password + // prompt is triggered; a nil stdin returns EOF immediately rather than + // blocking on terminal read. + cmd.Stdin = nil + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("ibcmd timed out after %s\noutput: %s", timeout, decodeForLog(out)) + } + if err != nil { + return fmt.Errorf("ibcmd %s: %w\noutput: %s", strings.Join(args[:min(3, len(args))], " "), err, decodeForLog(out)) + } + // ibcmd prints "[INFO] ..." lines on success; surface them to user. + if len(out) > 0 { + fmt.Println(decodeForLog(out)) + } + return nil +} + +// classifyIbcmdError converts low-level ibcmd errors into user-friendly messages +// with actionable hints. +func classifyIbcmdError(step string, err error, timeout time.Duration) error { + if err == nil { + return nil + } + msg := err.Error() + switch { + case strings.Contains(msg, "timed out"): + return fmt.Errorf("ibcmd %s timed out after %s — likely lock contention "+ + "(active rphost sessions hold extension metadata). "+ + "Try: (a) wait until base is idle and retry; "+ + "(b) increase --ibcmd-timeout; "+ + "(c) fall back to --installer=designer for busy bases.\nOriginal: %w", step, timeout, err) + case strings.Contains(msg, "Connection refused"), strings.Contains(msg, "connection failed"): + return fmt.Errorf("ibcmd %s: cannot reach database server. "+ + "Check --db-server hostname and port (use 'host port=N' WITHOUT trailing ';'). "+ + "For PostgreSQL: typical port is 5432 or 6432 (pgbouncer).\nOriginal: %w", step, err) + case strings.Contains(msg, "authentication failed"), strings.Contains(msg, "не верный пароль"): + return fmt.Errorf("ibcmd %s: authentication failed. "+ + "Check --db-user/--db-pwd for DBMS and --user/--password for infobase admin.\nOriginal: %w", step, err) + default: + return fmt.Errorf("ibcmd %s: %w", step, err) + } +} + +// decodeForLog ensures output is valid UTF-8 for display. +// +// ibcmd on Windows emits its log lines in the system OEM codepage when +// STDOUT is redirected — typically cp866 (for Russian Windows). If the +// raw bytes are not valid UTF-8, we try cp866 then windows-1251 as +// fallbacks. This mirrors the pattern used in installer.go for DESIGNER +// log files (Windows1251) but adds cp866 as the primary attempt because +// cmd.exe inherits OEM codepage for redirected output, not ANSI. +func decodeForLog(b []byte) string { + if utf8.Valid(b) { + return string(b) + } + // Try cp866 first (cmd.exe redirected stdout default on RU Windows). + if decoded, err := charmap.CodePage866.NewDecoder().Bytes(b); err == nil && utf8.Valid(decoded) { + return string(decoded) + } + // Fallback to windows-1251 (some 1C output uses ANSI codepage). + if decoded, err := charmap.Windows1251.NewDecoder().Bytes(b); err == nil && utf8.Valid(decoded) { + return string(decoded) + } + // Last resort: best-effort string (may contain replacement chars). + return string(b) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/installer/ibcmd_test.go b/installer/ibcmd_test.go new file mode 100644 index 0000000..c30cd7e --- /dev/null +++ b/installer/ibcmd_test.go @@ -0,0 +1,160 @@ +package installer + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// TestBuildConnectionArgs_FileMode verifies file-mode produces only --db-path. +func TestBuildConnectionArgs_FileMode(t *testing.T) { + opts := IbcmdOptions{DBPath: "/tmp/infobase", ServerMode: false} + args, err := buildConnectionArgs(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(args) != 1 || args[0] != "--db-path=/tmp/infobase" { + t.Errorf("expected --db-path arg, got %v", args) + } +} + +// TestBuildConnectionArgs_ServerMode verifies cluster-server parsing. +func TestBuildConnectionArgs_ServerMode(t *testing.T) { + opts := IbcmdOptions{ + DBPath: `cluster:1541\db1_staging`, + ServerMode: true, + DBUser: "dt_user", + DBPassword: "p@ss!w0rd", + } + args, err := buildConnectionArgs(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // CRITICAL: --db-server must NOT contain trailing ";" + // (this is the #1 gotcha we're codifying) + for _, a := range args { + if strings.HasPrefix(a, "--db-server=") && strings.HasSuffix(a, ";") { + t.Errorf("CRITICAL: --db-server has trailing semicolon (gotcha #1): %s", a) + } + } + + // Verify expected flags are present + want := map[string]bool{ + "--dbms=PostgreSQL": true, + "--db-server=cluster:1541": true, + "--db-name=db1_staging": true, + "--db-user=dt_user": true, + `--db-pwd=p@ss!w0rd`: true, + } + got := map[string]bool{} + for _, a := range args { + got[a] = true + } + for k := range want { + if !got[k] { + t.Errorf("expected arg %q not in %v", k, args) + } + } +} + +// TestBuildConnectionArgs_UserWithoutPasswordRejected verifies we prevent +// the STDIN-hang gotcha (#2). +func TestBuildConnectionArgs_UserWithoutPasswordRejected(t *testing.T) { + opts := IbcmdOptions{ + DBPath: `cluster\db`, + ServerMode: true, + DBUser: "admin", + // DBPassword intentionally empty + } + _, err := buildConnectionArgs(opts) + if err == nil { + t.Fatal("expected error for DBUser without DBPassword (would hang on STDIN), got nil") + } + if !strings.Contains(err.Error(), "DBPassword empty") { + t.Errorf("error should mention DBPassword: %v", err) + } +} + +// TestBuildConnectionArgs_ServerModeWithoutBackslashRejected verifies invalid DBPath. +func TestBuildConnectionArgs_ServerModeWithoutBackslashRejected(t *testing.T) { + opts := IbcmdOptions{ + DBPath: "no-backslash-here", + ServerMode: true, + } + _, err := buildConnectionArgs(opts) + if err == nil { + t.Fatal("expected error for DBPath without backslash in ServerMode") + } +} + +// TestFindIbcmd_NextToPlatform verifies path resolution. +func TestFindIbcmd_NextToPlatform(t *testing.T) { + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + t.Skip("test uses Windows-style path; run on Windows or refactor") + } + tmp := t.TempDir() + platform := filepath.Join(tmp, "1cv8.exe") + ibcmd := filepath.Join(tmp, "ibcmd.exe") + for _, p := range []string{platform, ibcmd} { + if err := os.WriteFile(p, []byte("stub"), 0o755); err != nil { + t.Fatal(err) + } + } + found, err := findIbcmd(platform) + if err != nil { + t.Fatalf("findIbcmd: %v", err) + } + if found != ibcmd { + t.Errorf("expected %s, got %s", ibcmd, found) + } +} + +// TestRunIbcmd_TimeoutKills verifies hard timeout enforcement. +// Uses a slow shell stub instead of real ibcmd. +func TestRunIbcmd_TimeoutKills(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stub uses /bin/sleep") + } + start := time.Now() + err := runIbcmd(context.Background(), "/bin/sleep", []string{"10"}, 500*time.Millisecond) + elapsed := time.Since(start) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("error should mention timeout: %v", err) + } + // Sanity: we killed it within ~1s, not waited the full 10s. + if elapsed > 2*time.Second { + t.Errorf("timeout enforcement too slow: %v elapsed (expected <2s)", elapsed) + } +} + +// TestClassifyIbcmdError_TimeoutMessage verifies user-friendly hint for lock contention. +func TestClassifyIbcmdError_TimeoutMessage(t *testing.T) { + original := errFromString("ibcmd timed out after 60s") + got := classifyIbcmdError("config import files", original, 60*time.Second) + if got == nil { + t.Fatal("expected non-nil error") + } + msg := got.Error() + for _, want := range []string{"lock contention", "--ibcmd-timeout", "--installer=designer"} { + if !strings.Contains(msg, want) { + t.Errorf("error message should mention %q: %s", want, msg) + } + } +} + +// errFromString is a test helper: wraps string in error. +func errFromString(s string) error { + return &simpleErr{s} +} + +type simpleErr struct{ msg string } + +func (e *simpleErr) Error() string { return e.msg }