Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,24 @@ node "$launcher" doctor

Use the external transport flags on these setup commands too, if applicable. For a read-only PATH diagnostic, run the package's `bin/check-install.js` through Node from your shell, outside npm lifecycle scripts. Shell aliases still require the shell checks above. Correct PATH or remove only the verified obsolete npm installation, then open a new terminal and recheck the version.

### Recover a stale OMP installation path

After switching OMP installation channels, PinkCollab may still point to the old launcher. Confirm the new installation works, inspect the saved entry, then run setup to approve a replacement:

```sh
omp --version
pinkcollab doctor
pinkcollab setup
```

- Setup keeps a working saved launcher. If it has failed, setup offers a validated PATH replacement; confirmation defaults to **No**. To select a launcher explicitly, use `pinkcollab setup --omp omp` or `pinkcollab setup --omp "/absolute/path/to/omp"`.
- For automation, provide `--non-interactive --workspace <existing-root> --omp <path-or-omp>`.
- For external HTTPS, add your existing `--transport external --public-url https://your-host` options to setup.
- Finish active work first: a required Gateway restart ends active OMP runtimes but preserves provider credentials, pairings, and saved sessions.
- If restart fails, the approved configuration remains saved. Resolve the service error, then run `pinkcollab service restart`; do not delete configuration or pairings.

If recovery still fails, locate the intended launcher with `type -a omp` on macOS/Linux or `Get-Command omp -All` in PowerShell, verify its `--version`, and select it with `setup --omp <path>`.

## Pairing and access

```sh
Expand Down
2 changes: 1 addition & 1 deletion gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ url = "2"

[target.'cfg(windows)'.dependencies]
windows-service = "0.8"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_UI_Shell"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_UI_Shell"] }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand Down
76 changes: 66 additions & 10 deletions gateway/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,38 @@ pub async fn status(dir: &Path) -> Result<()> {
);
Ok(())
}
fn doctor_lease_count(dir: &Path) -> Result<i64> {
let path = dir.join("pinkcollab.db");
if !path.try_exists()? {
return Ok(0);
}
let db =
rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let has_leases: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='runtime_leases')",
[],
|row| row.get(0),
)?;
if !has_leases {
return Ok(0);
}
Ok(db.query_row("SELECT COUNT(*) FROM runtime_leases", [], |row| row.get(0))?)
}

async fn doctor_omp_version(path: &Path) -> bool {
let version = omp::version(&workspace::display(path)).await;
if version == "unavailable" {
println!(" version: unavailable (--version failed or timed out)");
false
} else if version.trim().is_empty() {
println!(" version: unavailable (--version returned empty output)");
false
} else {
println!(" version: {version}");
true
}
}

/// Full diagnostics continue through independent failures, including an invalid config.
pub async fn doctor(dir: &Path) -> Result<()> {
let mut problems = Vec::new();
Expand All @@ -281,18 +313,42 @@ pub async fn doctor(dir: &Path) -> Result<()> {
};
println!("OMP");
let executable = config.as_ref().map_or("omp", |c| c.omp.as_str());
match config::resolve_executable(executable) {
println!(" configured entry: {executable}");
let configured_path = config::resolve_executable(executable);
let configured_valid = match &configured_path {
Some(path) => {
let version = omp::version(&workspace::display(&path)).await;
println!(" executable: {}\n version: {version}", path.display());
if version == "unavailable" {
problems.push("OMP");
}
println!(" executable: {}", path.display());
doctor_omp_version(path).await
}
None => {
println!(" executable: {executable} (unavailable)");
problems.push("OMP");
println!(" resolution: unavailable (missing or not executable)");
false
}
};
if !configured_valid {
problems.push("OMP");
match config::resolve_executable("omp") {
Some(path) => {
println!(" PATH candidate: {}", path.display());
if configured_path
.as_ref()
.is_some_and(|configured| config::same_executable_entry(configured, &path))
{
println!(
" PATH candidate validation: invalid (same entry already failed --version)"
);
println!(
" Fix this launcher or select a different entry with `pinkcollab setup --omp <path>`."
);
} else if doctor_omp_version(&path).await {
println!(" PATH candidate validation: valid");
} else {
println!(" PATH candidate validation: invalid");
}
}
None => println!(" PATH candidate: no executable omp found"),
}
println!(" Recovery: run `pinkcollab setup --omp omp` after validating OMP on PATH.");
}
let remote = config
.as_ref()
Expand Down Expand Up @@ -437,8 +493,8 @@ pub async fn doctor(dir: &Path) -> Result<()> {
" clients: {}\n sessions: {}",
counts.clients, counts.sessions
);
match Store::open(dir).and_then(|s| s.runtime_leases()) {
Ok(leases) => println!(" leases: {}", leases.len()),
match doctor_lease_count(dir) {
Ok(leases) => println!(" leases: {leases}"),
Err(error) => {
println!(" leases: unavailable ({error:#})");
problems.push("Database leases");
Expand Down
43 changes: 43 additions & 0 deletions gateway/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,49 @@ pub fn resolve_executable(name: &str) -> Option<PathBuf> {
.and_then(|candidate| std::path::absolute(candidate).ok())
})
}
/// Compares launcher entries without following their final symlink/reparse point.
/// Windows filesystem identity handles casing and aliases without folding distinct files
/// in case-sensitive directories. Other platforms retain lexical entry comparison.
pub fn same_executable_entry(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
#[cfg(windows)]
{
use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle};
use windows_sys::Win32::Storage::FileSystem::{
FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx,
};

fn identity(path: &Path) -> Option<(u64, [u8; 16])> {
let file = std::fs::OpenOptions::new()
.access_mode(0)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
.ok()?;
// FileIdInfo initializes the entire output structure on success.
let mut info: FILE_ID_INFO = unsafe { std::mem::zeroed() };
let success = unsafe {
GetFileInformationByHandleEx(
file.as_raw_handle(),
FileIdInfo,
(&raw mut info).cast(),
std::mem::size_of::<FILE_ID_INFO>() as u32,
)
};
(success != 0).then_some((info.VolumeSerialNumber, info.FileId.Identifier))
}

identity(left)
.zip(identity(right))
.is_some_and(|(left, right)| left == right)
}
#[cfg(not(windows))]
{
false
}
}

fn executable(path: &Path) -> bool {
#[cfg(unix)]
{
Expand Down
5 changes: 5 additions & 0 deletions gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ enum Commands {
/// Explicit HTTPS Gateway root URL for an externally managed reverse proxy.
#[arg(long)]
public_url: Option<String>,
/// Select and validate OMP explicitly; use "omp" to rediscover it on PATH.
#[arg(long)]
omp: Option<String>,
},
/// Manage the background Gateway.
#[command(display_order = 3)]
Expand Down Expand Up @@ -135,12 +138,14 @@ async fn main() -> Result<()> {
non_interactive,
transport,
public_url,
omp,
} => match setup::run(
&cli.data_dir,
&workspace,
non_interactive,
transport,
public_url.as_deref(),
omp.as_deref(),
)
.await
{
Expand Down
84 changes: 69 additions & 15 deletions gateway/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,79 @@ async fn enter(control: &Control, prompt: &str) -> Result<String> {
.await
}

async fn validated_omp(control: &Control, reference: &str) -> Result<(PathBuf, String)> {
let executable = config::resolve_executable(reference)
.with_context(|| format!("OMP entry {reference:?} is missing or not executable"))?;
let version = control
.wait(async { Ok(omp::version(&workspace::display(&executable)).await) })
.await?;
ensure!(
version != "unavailable" && !version.is_empty(),
"OMP entry {reference:?} failed `--version` (failed to start, nonzero exit, timeout, or empty output)"
);
Ok((executable, version))
}

async fn select_omp(
control: &Control,
configured: &str,
explicit: Option<&str>,
non_interactive: bool,
) -> Result<(PathBuf, String)> {
if let Some(reference) = explicit {
return validated_omp(control, reference).await.context(OMP_HELP);
}
let configured_path = config::resolve_executable(configured);
let failure = match validated_omp(control, configured).await {
Ok(selected) => return Ok(selected),
Err(error) => {
control.checkpoint().await?;
error
}
};
println!("Configured OMP: {} ({failure:#})", safe_text(configured));
let candidate_path = config::resolve_executable("omp");
ensure!(
!configured_path
.as_ref()
.zip(candidate_path.as_ref())
.is_some_and(|(configured, candidate)| config::same_executable_entry(
configured, candidate
)),
"{failure:#}\nPATH points to the same failed OMP entry; no replacement is available. Fix this launcher or use pinkcollab setup --omp <different-path>."
);
let candidate = validated_omp(control, "omp")
.await
.with_context(|| format!("{failure:#}\n{OMP_HELP}\n\nCheck PATH, then run pinkcollab setup --omp omp (with your workspace and transport options)."))?;
println!(
"PATH candidate: {}\nVersion: {}",
safe_text(&workspace::display(&candidate.0)),
safe_text(&candidate.1)
);
ensure!(
!non_interactive,
"OMP migration requires authorization. Run pinkcollab setup --omp omp with --non-interactive, --workspace and your transport options. Configuration was not changed."
);
let answer = enter(
control,
"Use this OMP instead of the configured entry? [y/N] ",
)
.await?;
ensure!(
matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes"),
"OMP migration declined. Configuration was not changed."
);
// Revalidate after the prompt; the launcher may have changed while awaiting approval.
validated_omp(control, &workspace::display(&candidate.0)).await
}

pub async fn run(
dir: &Path,
requested: &[PathBuf],
non_interactive: bool,
transport: Transport,
public_url: Option<&str>,
explicit_omp: Option<&str>,
) -> Result<()> {
transport.validate(public_url)?;
ensure!(
Expand Down Expand Up @@ -182,14 +249,8 @@ pub async fn run(
"Setup cancelled. Choose a directory with --workspace <directory>."
);
}
let executable = config::resolve_executable(&config.omp).context(OMP_HELP)?;
let version = control
.wait(async { Ok(omp::version(&workspace::display(&executable)).await) })
.await?;
ensure!(
version != "unavailable" && !version.is_empty(),
"{OMP_HELP}"
);
let (executable, version) =
select_omp(&control, &config.omp, explicit_omp, non_interactive).await?;
println!("✓ OMP {}", safe_text(&version));
control.checkpoint().await?;
let save_required = merge(&mut config, roots, &executable)? || !exists;
Expand Down Expand Up @@ -498,13 +559,6 @@ mod tests {
assert_eq!(config.max_sessions, 3);
}
#[test]
fn missing_omp_has_actionable_message() {
let error = config::resolve_executable("/missing/pinkcollab-test/omp")
.context(OMP_HELP)
.unwrap_err();
assert!(error.to_string().contains("omp --version"));
}
#[test]
fn pairing_wait_detects_new_devices_and_expiry_without_printing_credentials() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path()).unwrap();
Expand Down
Loading
Loading