Skip to content

Commit 9ae8fc1

Browse files
committed
feat(ru-code): install script improvments
1 parent ab589ee commit 9ae8fc1

8 files changed

Lines changed: 160 additions & 24 deletions

File tree

‎apps/server/src/ru-fork/preflight/common/constants.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export const CLI_DIR = CLI_CONFIG_DIRNAME;
99
export const APP_DIR = APP_HOME_DIRNAME;
1010
/** Our app command / wrapper name, e.g. "ru-fork". */
1111
export const APP_BIN = APP_HOME_SLUG;
12+
/** Linux-only path segment under /home. When /home/<LINUX_SAFE_DIR>/<user> exists,
13+
* OUR_ROOT is placed there; otherwise it stays in the home dir. */
14+
export const LINUX_SAFE_DIR = "work";
1215

1316
export const NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10";
1417
/** Minimum CLI version; "" disables the version check (presence only). */

‎apps/server/src/ru-fork/preflight/common/messages.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ export const MESSAGES = {
1818
CLI_BROKEN: "CLI установлен, но `cli.js --version` завершилась с ошибкой или превысила тайм-аут.",
1919
CLI_LOW: `CLI {found} установлен, требуется ≥ ${CLI_MIN_VERSION}. Обновите: npm install -g ${CLI_NPM_PACKAGE}@latest`,
2020
FOOTER_FAIL: "Установите недостающие компоненты и перезапустите.",
21+
USING_BACKUP_PRIORITY:
22+
"Используется резервный CLI (TRY_TO_FIND_CLI имеет приоритет над основным).",
2123
} as const;

‎apps/server/src/ru-fork/preflight/common/resolve.ts‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import * as os from "node:os";
1313
import * as path from "node:path";
1414

1515
import { CLI_BIN_PATHS, CONFIG } from "../paths.ts";
16-
import { APP_DIR, CLI_DIR } from "./constants.ts";
16+
import { APP_DIR, CLI_DIR, LINUX_SAFE_DIR } from "./constants.ts";
1717
import { expand } from "./expand.ts";
1818
import { isDir, isFile, readInstallRecord } from "./fs.ts";
1919
import { MESSAGES } from "./messages.ts";
@@ -22,6 +22,25 @@ import type { CliResolution, PlatformKey, ResolveOptions } from "./types.ts";
2222
const toPlatformKey = (platform: NodeJS.Platform): PlatformKey =>
2323
platform === "darwin" ? "darwin" : platform === "win32" ? "win32" : "linux";
2424

25+
// Linux OUR_ROOT placement. When /home/<LINUX_SAFE_DIR>/<user> exists, the app
26+
// root goes there; otherwise it stays in the home dir. <user> = the real account
27+
// name (os.userInfo) — matches /home/work/<user> even when the home FOLDER name
28+
// differs from the username. Pure: returns paths only; the installer deletes the
29+
// orphaned home copy reported as legacyRoot.
30+
const linuxRoots = (): { readonly ourRoot: string; readonly legacyRoot?: string } => {
31+
const homeRoot = path.join(os.homedir(), APP_DIR);
32+
let username = "";
33+
try {
34+
username = os.userInfo().username;
35+
} catch {
36+
// No passwd entry (rare) — stay in home.
37+
}
38+
if (!username) return { ourRoot: homeRoot };
39+
const preferredDir = path.join("/home", LINUX_SAFE_DIR, username);
40+
if (!isDir(preferredDir)) return { ourRoot: homeRoot };
41+
return { ourRoot: path.join(preferredDir, APP_DIR), legacyRoot: homeRoot };
42+
};
43+
2544
/**
2645
* Step 3 → FALLBACK: probe CLI_BIN_PATHS. Returns the resolved cli.js, or the
2746
* list of probed paths (when nothing matched) so the caller can STOP + report.
@@ -65,6 +84,12 @@ export const resolveCli = (options: ResolveOptions = {}): CliResolution => {
6584
return { ok: false, reason: MESSAGES.CONFIG_NOT_FOUND, details: checkedConfigPaths };
6685
}
6786

87+
// Linux overrides OUR_ROOT via the preferred-dir rule; mac/Windows keep the
88+
// per-source derivation. pickRoot applies that override only on Linux.
89+
const roots = platformKey === "linux" ? linuxRoots() : null;
90+
const pickRoot = (nonLinux: string): string => (roots ? roots.ourRoot : nonLinux);
91+
const legacyRoot = roots?.legacyRoot;
92+
6893
// STEP 2 — gather the two authoritative sources.
6994
const homeBinCli = path.join(configDir, "bin", "cli.js");
7095
const recordedBinDir = readInstallRecord(configDir);
@@ -76,15 +101,17 @@ export const resolveCli = (options: ResolveOptions = {}): CliResolution => {
76101
configDir,
77102
cliJs: homeBinCli,
78103
source: "standard",
79-
ourRoot: path.join(path.dirname(configDir), APP_DIR),
104+
ourRoot: pickRoot(path.join(path.dirname(configDir), APP_DIR)),
105+
...(legacyRoot ? { legacyRoot } : {}),
80106
});
81107

82108
const resolveFromInstallRecord = (): CliResolution => ({
83109
ok: true,
84110
configDir,
85111
cliJs: recordedCli,
86112
source: "install-dir",
87-
ourRoot: path.join(path.dirname(path.dirname(recordedBinDir)), APP_DIR),
113+
ourRoot: pickRoot(path.join(path.dirname(path.dirname(recordedBinDir)), APP_DIR)),
114+
...(legacyRoot ? { legacyRoot } : {}),
88115
});
89116

90117
const resolveFromFallback = (): CliResolution => {
@@ -95,12 +122,41 @@ export const resolveCli = (options: ResolveOptions = {}): CliResolution => {
95122
configDir,
96123
cliJs: fallback.cliJs,
97124
source: "fallback",
98-
ourRoot: path.join(os.homedir(), APP_DIR),
125+
ourRoot: pickRoot(path.join(os.homedir(), APP_DIR)),
126+
...(legacyRoot ? { legacyRoot } : {}),
99127
};
100128
}
101129
return { ok: false, reason: MESSAGES.CLI_NOT_FOUND, details: fallback.stop, configDir };
102130
};
103131

132+
// TRY_TO_FIND_CLI = "use my local CLI". When on and CLI_BIN_PATHS has a live
133+
// cli.js, it IS the primary and WINS unconditionally. We report the state of
134+
// both primary candidates (home bin + .install-dir) so support sees what was
135+
// bypassed; none of it STOPs. Flag off / no backup → fall through below.
136+
if (tryFindCli) {
137+
const probed = tryFallbackBinPaths(platformKey, env, true);
138+
if ("cliJs" in probed) {
139+
const warnings: string[] = [MESSAGES.USING_BACKUP_PRIORITY];
140+
if (isFile(homeBinCli)) warnings.push(`основной CLI (home): ${homeBinCli} — live`);
141+
warnings.push(
142+
recordedBinDir
143+
? `.install-dir → ${recordedBinDir} — cli.js ${isFile(recordedCli) ? "live" : "ОТСУТСТВУЕТ (dead)"}`
144+
: ".install-dir отсутствует",
145+
);
146+
warnings.push(`резервный CLI: ${probed.cliJs}`);
147+
return {
148+
ok: true,
149+
configDir,
150+
cliJs: probed.cliJs,
151+
source: "fallback",
152+
ourRoot: pickRoot(path.join(os.homedir(), APP_DIR)),
153+
...(legacyRoot ? { legacyRoot } : {}),
154+
warnings,
155+
};
156+
}
157+
// no backup found → fall through to authoritative resolution unchanged
158+
}
159+
104160
// A = home bin exists → standard (must agree with .install-dir if present).
105161
if (isFile(homeBinCli)) {
106162
if (recordedCli && path.normalize(recordedCli) !== path.normalize(homeBinCli)) {

‎apps/server/src/ru-fork/preflight/common/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export type CliResolution =
2020
readonly cliJs: string;
2121
readonly source: CliSource;
2222
readonly ourRoot: string;
23+
/** Linux only: orphaned {home}/.<app> to delete after relocation. */
24+
readonly legacyRoot?: string;
25+
/** Non-fatal report lines (e.g. which primary CLIs the backup bypassed). */
26+
readonly warnings?: ReadonlyArray<string>;
2327
}
2428
| {
2529
readonly ok: false;

‎apps/server/src/ru-fork/preflight/preflight-install.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,14 @@ const main = (): void => {
6868
process.stdout.write(`CLI_JS=${cliJs}\n`);
6969
process.stdout.write(`CONFIG_DIR=${configDir}\n`);
7070
process.stdout.write(`SOURCE=${source}\n`);
71+
if (resolution.legacyRoot) process.stdout.write(`LEGACY_ROOT=${resolution.legacyRoot}\n`);
7172

7273
// Mandatory location log lines (every successful resolution, all platforms).
7374
writeInfo(`CLI config dir : ${configDir}`);
7475
if (source === "fallback") writeWarn(`cli.js в АЛЬТЕРНАТИВНОМ пути: ${cliJs}`);
7576
writeInfo(`CLI bin (cli.js): ${cliJs} [source: ${source}]`);
7677
writeInfo(`app root : ${ourRoot}`);
78+
resolution.warnings?.forEach(writeWarn);
7779

7880
// STEP 4–6 — dependency checks. Skip the cli probe if node is out of range so
7981
// the failure reads as "node", not a confusing cli error. (nodeCheck computed

‎apps/server/src/ru-fork/preflight/preflight-startup.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,7 @@ export const resolveStartupCli: Effect.Effect<PreflightResolution, PreflightFail
7373

7474
// The three lines support reads to know everything (mirrors install §10).
7575
yield* Effect.logInfo(`CLI config dir : ${resolution.configDir}`);
76-
yield* Effect.logInfo(
77-
`CLI bin (cli.js): ${resolution.cliJs} [source: ${resolution.source}]`,
78-
);
76+
yield* Effect.logInfo(`CLI bin (cli.js): ${resolution.cliJs} [source: ${resolution.source}]`);
7977
yield* Effect.logInfo(`app root : ${resolution.ourRoot}`);
8078

8179
return {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "feat(ru-code): install script improvments",
4+
"packageName": "@ru-code/ru-code",
5+
"email": "53821477+ru-code-dev@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

‎install‎

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ NODE_PATH=""
7373
APP_ROOT="" # OUR_ROOT — the app folder, e.g. .../.ru-fork
7474
APP_BIN="" # the command / wrapper name
7575
APP_DIR_NAME="" # basename(APP_ROOT), e.g. .ru-fork — drives rc cleanup
76+
LEGACY_ROOT="" # Linux: orphaned {home}/.<app> to delete after relocation
7677
NODE_OK="" # 1 if node is compatible (gates the old app's `stop`)
7778
BIN_DIR="" # APP_ROOT/bin
7879
PREFLIGHT_STATUS=1 # 0 only when the environment is compatible
@@ -132,11 +133,15 @@ resolve_bundle_dir() {
132133

133134
remove_source_dir() {
134135
[ -n "$SOURCE_DIR" ] || return 0
135-
[ "$KEEP_SOURCE" = false ] || { info "Исходный каталог сохранён: $SOURCE_DIR"; return 0; }
136-
case "$SOURCE_DIR" in
137-
/|/home|/Users|"$HOME") warn "Отказ удалять: $SOURCE_DIR"; return 0 ;;
136+
# Capture + clear up front so this is idempotent: the success path and the
137+
# on_exit trap (and the failure dies) all call it, but it acts exactly once.
138+
local dir="$SOURCE_DIR"
139+
SOURCE_DIR=""
140+
[ "$KEEP_SOURCE" = false ] || { info "Исходный каталог сохранён: $dir"; return 0; }
141+
case "$dir" in
142+
/|/home|/Users|"$HOME") warn "Отказ удалять: $dir"; return 0 ;;
138143
esac
139-
rm -rf "$SOURCE_DIR" 2>/dev/null && info "Исходный каталог удалён: $SOURCE_DIR" || true
144+
rm -rf "$dir" 2>/dev/null && info "Исходный каталог удалён: $dir" || true
140145
}
141146

142147
# ---------------------------------------------------------------------------
@@ -161,11 +166,17 @@ run_preflight() {
161166
PREFLIGHT_STATUS=0
162167
out=$("$NODE_PATH" "$script") || PREFLIGHT_STATUS=$?
163168

164-
APP_ROOT=$(printf '%s\n' "$out" | grep '^OUR_ROOT=' | head -n 1 | cut -d'=' -f2-)
165-
APP_BIN=$(printf '%s\n' "$out" | grep '^APP_BIN=' | head -n 1 | cut -d'=' -f2-)
166-
NODE_OK=$(printf '%s\n' "$out" | grep '^NODE_OK=' | head -n 1 | cut -d'=' -f2-)
169+
# `|| true` on every extraction: a no-match grep (the preflight failed and
170+
# emitted no keys, or LEGACY_ROOT is absent without a relocation) must not trip
171+
# set -e/pipefail. Empty values are fine — the empty-APP_ROOT check below then
172+
# reports a clean error and rolls back.
173+
APP_ROOT=$(printf '%s\n' "$out" | grep '^OUR_ROOT=' | head -n 1 | cut -d'=' -f2- || true)
174+
APP_BIN=$(printf '%s\n' "$out" | grep '^APP_BIN=' | head -n 1 | cut -d'=' -f2- || true)
175+
NODE_OK=$(printf '%s\n' "$out" | grep '^NODE_OK=' | head -n 1 | cut -d'=' -f2- || true)
176+
LEGACY_ROOT=$(printf '%s\n' "$out" | grep '^LEGACY_ROOT=' | head -n 1 | cut -d'=' -f2- || true)
167177

168178
APP_ROOT="${APP_ROOT//\\//}"
179+
LEGACY_ROOT="${LEGACY_ROOT//\\//}"
169180
[ -n "$INSTALL_DIR" ] && APP_ROOT="${INSTALL_DIR//\\//}"
170181
[ -n "$APP_ROOT" ] || { remove_source_dir; die "preflight: путь установки не получен."; }
171182

@@ -218,13 +229,22 @@ clean_rc() {
218229
done < <(rc_files)
219230
}
220231

221-
# Stop the old app via node on its installed cli.js — only when node is
222-
# compatible (it can't run otherwise). No-op if there is no old install.
223-
stop_old() {
232+
# Stop a running daemon via node on the cli.js in bin dir $1 — only when node is
233+
# compatible (it can't run otherwise). No-op if that cli.js is absent.
234+
stop_daemon_at() {
235+
local bin="$1"
224236
[ "$NODE_OK" = "1" ] || return 0
225-
[ -f "$BIN_DIR/cli.js" ] || return 0
226-
info "Остановка приложения..."
227-
"$NODE_PATH" $NODE_FLAGS "$BIN_DIR/cli.js" stop >/dev/null 2>&1 || true
237+
[ -n "$bin" ] && [ -f "$bin/cli.js" ] || return 0
238+
info "Остановка приложения: $bin"
239+
"$NODE_PATH" $NODE_FLAGS "$bin/cli.js" stop >/dev/null 2>&1 || true
240+
}
241+
242+
# Stop both the new install and (after a relocation) the old home install.
243+
# (uses `if`, not `&&`, so an empty LEGACY_ROOT can't make the function return
244+
# non-zero and trip set -e in the caller.)
245+
stop_old() {
246+
stop_daemon_at "$BIN_DIR"
247+
if [ -n "${LEGACY_ROOT:-}" ]; then stop_daemon_at "$LEGACY_ROOT/bin"; fi
228248
}
229249

230250
remove_bin() {
@@ -239,6 +259,25 @@ remove_bin() {
239259
rm -rf "$BIN_DIR"
240260
}
241261

262+
# Remove the orphaned home install after the resolver relocated OUR_ROOT to the
263+
# preferred dir (LEGACY_ROOT, Linux only). On install this runs only AFTER verify
264+
# (so a failed relocation never wipes a working old install); do_uninstall calls
265+
# it too. Guarded: must exist, end in the app dir name, not a dangerous path.
266+
remove_legacy_root() {
267+
[ -n "${LEGACY_ROOT:-}" ] && [ -d "$LEGACY_ROOT" ] || return 0
268+
# Without the app dir name the */<appdir> guard below can't be trusted — skip.
269+
[ -n "$APP_DIR_NAME" ] || { warn "Отказ удалять: имя каталога приложения не определено"; return 0; }
270+
case "$LEGACY_ROOT" in
271+
/|/home|/Users|"$HOME") warn "Отказ удалять: $LEGACY_ROOT"; return 0 ;;
272+
esac
273+
case "$LEGACY_ROOT" in
274+
*/"$APP_DIR_NAME") ;;
275+
*) warn "Отказ удалять подозрительный LEGACY_ROOT: $LEGACY_ROOT"; return 0 ;;
276+
esac
277+
info "Удаление старой установки в домашнем каталоге: $LEGACY_ROOT"
278+
rm -rf "$LEGACY_ROOT" 2>/dev/null || true
279+
}
280+
242281
# Full teardown — used by the crash rollback (no stop; the new install never
243282
# fully started).
244283
tear_down() {
@@ -322,13 +361,31 @@ WRAPPER
322361
chmod +x "$BIN_DIR/$APP_BIN"
323362
}
324363

364+
# Convert a "C:/path" (drive-letter, forward slashes) to MSYS "/c/path" so it can
365+
# live in Git Bash's colon-separated PATH — the drive colon would otherwise split
366+
# the entry into "C" + "/path". Non-Windows paths pass straight through.
367+
to_msys_path() {
368+
local p="$1"
369+
if [[ "$p" =~ ^([A-Za-z]):/(.*)$ ]]; then
370+
# tr (not bash-4 ${x,,}) so this also works on the bash 3.2 macOS ships.
371+
local drive; drive=$(printf '%s' "${BASH_REMATCH[1]}" | tr 'A-Z' 'a-z')
372+
printf '/%s/%s' "$drive" "${BASH_REMATCH[2]}"
373+
else
374+
printf '%s' "$p"
375+
fi
376+
}
377+
325378
# Append a fresh PATH line. clean_rc already removed any prior ones, so it can
326379
# never duplicate.
327380
add_path() {
328-
local line="export PATH=\"${BIN_DIR}:\$PATH\"" rc added=false
381+
# Git Bash PATH is colon-separated, so a Windows "C:/..." entry splits at the
382+
# drive colon. Use MSYS form "/c/..." on Windows; pass-through elsewhere.
383+
local bin_for_path="$BIN_DIR"
384+
[ "$OS" = "windows" ] && bin_for_path="$(to_msys_path "$BIN_DIR")"
385+
local line="export PATH=\"${bin_for_path}:\$PATH\"" rc added=false
329386
while IFS= read -r rc; do
330387
[ -f "$rc" ] || continue
331-
# A read-only rc (e.g. corp-managed .bash_profile) must NOT abort the install
388+
# A read-only rc (e.g. a managed/locked .bash_profile) must NOT abort the install
332389
# under `set -e`: catch the failed redirect, warn, and try the next file.
333390
if printf '%s\n' "$line" >> "$rc" 2>/dev/null; then
334391
added=true
@@ -338,7 +395,7 @@ add_path() {
338395
fi
339396
done < <(rc_files)
340397
[ "$added" = true ] || die "Не удалось записать PATH ни в один файл оболочки. Добавьте вручную: $line"
341-
export PATH="$BIN_DIR:$PATH"
398+
export PATH="$bin_for_path:$PATH"
342399
}
343400

344401
verify() {
@@ -351,6 +408,7 @@ do_uninstall() {
351408
run_preflight
352409
stop_old
353410
remove_bin
411+
remove_legacy_root
354412
clean_rc
355413
info "Удалено."
356414
}
@@ -363,9 +421,14 @@ do_uninstall() {
363421
on_exit() {
364422
local status=$?
365423
[ -n "${TEMP_DIR:-}" ] && rm -rf "$TEMP_DIR" 2>/dev/null || true
424+
# The cloned installer dir is ALWAYS removed on exit (success or fail); only
425+
# --keep-source spares it (checked inside remove_source_dir). Closes the gap
426+
# where early failures (before BIN_DIR was set) left the clone behind.
427+
remove_source_dir
428+
# The freshly-laid bin is torn down only on a real failure (needs BIN_DIR for
429+
# its app-dir-name safety guard); a successful install keeps it.
366430
if [ "$status" -ne 0 ] && [ -n "${BIN_DIR:-}" ]; then
367431
tear_down
368-
remove_source_dir
369432
fi
370433
return "$status"
371434
}
@@ -501,6 +564,7 @@ main() {
501564
create_wrapper
502565
add_path
503566
verify
567+
remove_legacy_root
504568
remove_source_dir
505569
# Banner to stderr so it lands in order with the rest of the output.
506570
print_summary >&2

0 commit comments

Comments
 (0)