Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
464 lines
14 KiB
OCaml
464 lines
14 KiB
OCaml
type error = EAGAIN | EWOULDBLOCK | ETIMEDOUT | ESRCH | EPERM
|
|
type access_permission = R_OK | W_OK
|
|
type open_flag = O_RDWR
|
|
type file_descr = int
|
|
type stats = { st_size : int; st_mtime : float }
|
|
type process_result = { status : int; stdout : string; stderr : string }
|
|
type mkdir_result = Created | Already_exists
|
|
|
|
exception Cli_unix_error of error * string * string
|
|
|
|
module Fs = struct
|
|
type mkdir_options
|
|
type rm_options
|
|
type stat
|
|
|
|
external mkdir_options : mode:int -> unit -> mkdir_options = "" [@@mel.obj]
|
|
|
|
external rm_options : recursive:bool -> force:bool -> unit -> rm_options = ""
|
|
[@@mel.obj]
|
|
|
|
external mkdir_sync : string -> mkdir_options -> unit = "mkdirSync"
|
|
[@@mel.module "fs"]
|
|
|
|
external stat_sync : string -> stat = "statSync" [@@mel.module "fs"]
|
|
|
|
external read_fd_utf8_sync : int -> (_[@mel.as "utf8"]) -> string
|
|
= "readFileSync"
|
|
[@@mel.module "fs"]
|
|
|
|
external copy_file_sync : string -> string -> unit = "copyFileSync"
|
|
[@@mel.module "fs"]
|
|
|
|
external rm_sync : string -> rm_options -> unit = "rmSync" [@@mel.module "fs"]
|
|
external chmod_sync : string -> int -> unit = "chmodSync" [@@mel.module "fs"]
|
|
|
|
external access_sync : string -> int -> unit = "accessSync"
|
|
[@@mel.module "fs"]
|
|
|
|
external open_sync : string -> string -> int = "openSync" [@@mel.module "fs"]
|
|
external close_sync : int -> unit = "closeSync" [@@mel.module "fs"]
|
|
external mkdtemp_sync : string -> string = "mkdtempSync" [@@mel.module "fs"]
|
|
external size : stat -> int = "size" [@@mel.get]
|
|
external mtime_ms : stat -> float = "mtimeMs" [@@mel.get]
|
|
external is_directory : stat -> bool = "isDirectory" [@@mel.send]
|
|
end
|
|
|
|
module Os = struct
|
|
external hostname : unit -> string = "hostname" [@@mel.module "os"]
|
|
external tmpdir : unit -> string = "tmpdir" [@@mel.module "os"]
|
|
end
|
|
|
|
module Process = struct
|
|
type writable
|
|
|
|
external pid : int = "pid" [@@mel.module "process"]
|
|
external stdout : writable = "stdout" [@@mel.module "process"]
|
|
external kill : int -> int -> unit = "kill" [@@mel.module "process"]
|
|
external write : writable -> string -> bool = "write" [@@mel.send]
|
|
end
|
|
|
|
module Child_process = struct
|
|
type spawn_options
|
|
type spawn_sync_options
|
|
type child
|
|
type spawn_result
|
|
|
|
external spawn_options :
|
|
?detached:bool ->
|
|
?stdio:Js.Json.t array ->
|
|
?env:string Js.Dict.t ->
|
|
?shell:bool ->
|
|
unit ->
|
|
spawn_options = ""
|
|
[@@mel.obj]
|
|
|
|
external spawn_sync_options :
|
|
?encoding:string ->
|
|
?env:string Js.Dict.t ->
|
|
?shell:bool ->
|
|
unit ->
|
|
spawn_sync_options = ""
|
|
[@@mel.obj]
|
|
|
|
external spawn : string -> string array -> spawn_options -> child = "spawn"
|
|
[@@mel.module "child_process"]
|
|
|
|
external spawn_sync :
|
|
string -> string array -> spawn_sync_options -> spawn_result = "spawnSync"
|
|
[@@mel.module "child_process"]
|
|
|
|
external pid : child -> int Js.nullable = "pid" [@@mel.get]
|
|
external unref : child -> unit = "unref" [@@mel.send]
|
|
external status : spawn_result -> int Js.nullable = "status" [@@mel.get]
|
|
external stdout : spawn_result -> string Js.nullable = "stdout" [@@mel.get]
|
|
external stderr : spawn_result -> string Js.nullable = "stderr" [@@mel.get]
|
|
external error : spawn_result -> Js.Exn.t Js.nullable = "error" [@@mel.get]
|
|
end
|
|
|
|
module Atomics_sleep = struct
|
|
type shared_array_buffer
|
|
type int32_array
|
|
|
|
external make_shared_array_buffer : int -> shared_array_buffer
|
|
= "SharedArrayBuffer"
|
|
[@@mel.new]
|
|
|
|
external make_int32_array : shared_array_buffer -> int32_array = "Int32Array"
|
|
[@@mel.new]
|
|
|
|
external wait : int32_array -> int -> int -> int -> string = "wait"
|
|
[@@mel.scope "Atomics"]
|
|
|
|
let buffer = make_int32_array (make_shared_array_buffer 4)
|
|
let sleep_ms ms = ignore (wait buffer 0 0 ms : string)
|
|
end
|
|
|
|
external error_code : Js.Exn.t -> string option = "code" [@@mel.get]
|
|
|
|
let js_error_message exn =
|
|
match Js.Exn.asJsExn exn with
|
|
| Some error ->
|
|
Option.value (Js.Exn.message error) ~default:(Printexc.to_string exn)
|
|
| None -> Printexc.to_string exn
|
|
|
|
let js_error_code exn =
|
|
match Js.Exn.asJsExn exn with Some error -> error_code error | None -> None
|
|
|
|
let unix_error_of_code = function
|
|
| Some "EAGAIN" -> EAGAIN
|
|
| Some "EWOULDBLOCK" -> EWOULDBLOCK
|
|
| Some "ETIMEDOUT" -> ETIMEDOUT
|
|
| Some ("ENOENT" | "ESRCH") -> ESRCH
|
|
| Some ("EACCES" | "EPERM") -> EPERM
|
|
| _ -> EPERM
|
|
|
|
let raise_error ?error op detail exn =
|
|
let detail =
|
|
let message = js_error_message exn in
|
|
if String.trim message = "" then detail else message
|
|
in
|
|
let error =
|
|
Option.value error ~default:(unix_error_of_code (js_error_code exn))
|
|
in
|
|
raise (Cli_unix_error (error, op, detail))
|
|
|
|
let run_unit op detail fn = try fn () with exn -> raise_error op detail exn
|
|
|
|
let mkdir path perm =
|
|
run_unit "mkdir" path (fun () ->
|
|
Fs.mkdir_sync path (Fs.mkdir_options ~mode:perm ()))
|
|
|
|
let rmdir path = run_unit "rmdir" path (fun () -> Node.Fs.rmdirSync path)
|
|
|
|
let mkdir_exclusive path perm =
|
|
try
|
|
Fs.mkdir_sync path (Fs.mkdir_options ~mode:perm ());
|
|
Created
|
|
with exn -> (
|
|
match js_error_code exn with
|
|
| Some "EEXIST" -> Already_exists
|
|
| _ -> raise_error "mkdir" path exn)
|
|
|
|
let file_exists = Node.Fs.existsSync
|
|
|
|
let is_directory path =
|
|
try Fs.is_directory (Fs.stat_sync path) with _ -> false
|
|
|
|
let readdir path =
|
|
try Node.Fs.readdirSync path with exn -> raise_error "readdir" path exn
|
|
|
|
let rec mkdir_p path =
|
|
if path = "" || path = Filename.dirname path || file_exists path then ()
|
|
else (
|
|
mkdir_p (Filename.dirname path);
|
|
match mkdir_exclusive path 0o755 with Created | Already_exists -> ())
|
|
|
|
let write_text_file path content =
|
|
run_unit "write" path (fun () -> Node.Fs.writeFileAsUtf8Sync path content)
|
|
|
|
let read_text_file path =
|
|
try Node.Fs.readFileAsUtf8Sync path with exn -> raise_error "read" path exn
|
|
|
|
let read_stdin_all () =
|
|
try Fs.read_fd_utf8_sync 0 with exn -> raise_error "read" "stdin" exn
|
|
|
|
let write_binary_file path content =
|
|
run_unit "write" path (fun () -> Node.Fs.writeFileSync path content `latin1)
|
|
|
|
let read_binary_file path =
|
|
try Node.Fs.readFileSync path `latin1
|
|
with exn -> raise_error "read" path exn
|
|
|
|
let copy_file source destination =
|
|
run_unit "copy" source (fun () -> Fs.copy_file_sync source destination)
|
|
|
|
let remove_tree path =
|
|
run_unit "rm" path (fun () ->
|
|
Fs.rm_sync path (Fs.rm_options ~recursive:true ~force:true ()))
|
|
|
|
let rename source destination =
|
|
run_unit "rename" source (fun () -> Node.Fs.renameSync source destination)
|
|
|
|
let getpid () = Process.pid
|
|
|
|
let process_running pid =
|
|
pid > 0
|
|
&&
|
|
try
|
|
Process.kill pid 0;
|
|
true
|
|
with exn -> (
|
|
match js_error_code exn with Some "ESRCH" -> false | _ -> true)
|
|
|
|
let chmod path perm = run_unit "chmod" path (fun () -> Fs.chmod_sync path perm)
|
|
|
|
let access path permissions =
|
|
let flag =
|
|
Rrbvec.fold_left
|
|
(fun acc -> function R_OK -> acc lor 4 | W_OK -> acc lor 2)
|
|
0 permissions
|
|
in
|
|
try Fs.access_sync path flag
|
|
with _ ->
|
|
if file_exists path then raise (Cli_unix_error (EPERM, "access", path))
|
|
else raise (Cli_unix_error (ESRCH, "access", path))
|
|
|
|
let stat path =
|
|
try
|
|
let stat = Fs.stat_sync path in
|
|
{ st_size = Fs.size stat; st_mtime = Fs.mtime_ms stat /. 1000. }
|
|
with exn -> raise_error "stat" path exn
|
|
|
|
let environment () =
|
|
Node.Process.process##env |> Js.Dict.entries
|
|
|> Array.map (fun (key, value) -> key ^ "=" ^ value)
|
|
|
|
let gethostname () = try Os.hostname () with _ -> ""
|
|
let openfile _path _flags _perm = 0
|
|
let close _fd = ()
|
|
|
|
let command_args argv =
|
|
let argv = Rrbvec.of_array argv in
|
|
match Rrbvec.pop_front argv with
|
|
| Some ("env", rest) -> (
|
|
match Rrbvec.pop_front rest with
|
|
| Some (command, args) -> (command, args)
|
|
| None ->
|
|
raise
|
|
(Cli_unix_error (EPERM, "create_process_env", "missing command")))
|
|
| Some (command, args) -> (command, args)
|
|
| None ->
|
|
raise (Cli_unix_error (EPERM, "create_process_env", "missing command"))
|
|
|
|
let copy_env env =
|
|
let result = Js.Dict.empty () in
|
|
Node.Process.process##env |> Js.Dict.entries
|
|
|> Array.iter (fun (key, value) -> Js.Dict.set result key value);
|
|
Array.iter
|
|
(fun entry ->
|
|
match String.index_opt entry '=' with
|
|
| None -> ()
|
|
| Some index ->
|
|
Js.Dict.set result (String.sub entry 0 index)
|
|
(String.sub entry (index + 1) (String.length entry - index - 1)))
|
|
env;
|
|
result
|
|
|
|
let stdio_ignore =
|
|
[|
|
|
Js.Json.string "ignore"; Js.Json.string "ignore"; Js.Json.string "ignore";
|
|
|]
|
|
|
|
let spawn_detached command args env =
|
|
let child =
|
|
Child_process.spawn command (Rrbvec.to_array args)
|
|
(Child_process.spawn_options ~detached:true ~stdio:stdio_ignore
|
|
~env:(copy_env env) ~shell:false ())
|
|
in
|
|
Child_process.unref child;
|
|
Child_process.pid child |> Js.Nullable.toOption |> Option.value ~default:0
|
|
|
|
let windows_verbatim_spawn_options env : Child_process.spawn_options =
|
|
let make : string Js.Dict.t -> Js.Json.t array -> Child_process.spawn_options
|
|
=
|
|
[%mel.raw
|
|
{|
|
|
function (env, stdio) {
|
|
return {
|
|
detached: true,
|
|
stdio: stdio,
|
|
env: env,
|
|
shell: false,
|
|
windowsVerbatimArguments: true
|
|
};
|
|
}
|
|
|}]
|
|
in
|
|
make (copy_env env) stdio_ignore
|
|
|
|
let spawn_detached_windows_verbatim command args env =
|
|
let child =
|
|
Child_process.spawn command (Rrbvec.to_array args)
|
|
(windows_verbatim_spawn_options env)
|
|
in
|
|
Child_process.unref child;
|
|
Child_process.pid child |> Js.Nullable.toOption |> Option.value ~default:0
|
|
|
|
let create_process_env _prog argv env _stdin _stdout _stderr =
|
|
let command, args = command_args argv in
|
|
try spawn_detached command args env
|
|
with exn -> raise_error "create_process_env" command exn
|
|
|
|
let nullable_string_default value =
|
|
value |> Js.Nullable.toOption |> Option.value ~default:""
|
|
|
|
let run_process_capture command args env =
|
|
let result =
|
|
Child_process.spawn_sync command (Rrbvec.to_array args)
|
|
(Child_process.spawn_sync_options ~encoding:"utf8" ~env:(copy_env env)
|
|
~shell:false ())
|
|
in
|
|
let status =
|
|
result |> Child_process.status |> Js.Nullable.toOption
|
|
|> Option.value ~default:1
|
|
in
|
|
let stderr =
|
|
match result |> Child_process.error |> Js.Nullable.toOption with
|
|
| Some error ->
|
|
Option.value (Js.Exn.message error)
|
|
~default:(nullable_string_default (Child_process.stderr result))
|
|
| None -> nullable_string_default (Child_process.stderr result)
|
|
in
|
|
{
|
|
status;
|
|
stdout = nullable_string_default (Child_process.stdout result);
|
|
stderr;
|
|
}
|
|
|
|
let read_text_file_default path =
|
|
try Node.Fs.readFileAsUtf8Sync path with _ -> ""
|
|
|
|
let contains_substring ~needle haystack =
|
|
let needle_len = String.length needle in
|
|
let haystack_len = String.length haystack in
|
|
let rec loop index =
|
|
if needle_len = 0 then true
|
|
else if index + needle_len > haystack_len then false
|
|
else if String.sub haystack index needle_len = needle then true
|
|
else loop (index + 1)
|
|
in
|
|
loop 0
|
|
|
|
let split_lines text =
|
|
let len = String.length text in
|
|
let rec loop start index acc =
|
|
if index = len then
|
|
Rrbvec.push_back acc (String.sub text start (index - start))
|
|
else if text.[index] = '\n' then
|
|
let acc = Rrbvec.push_back acc (String.sub text start (index - start)) in
|
|
loop (index + 1) (index + 1) acc
|
|
else loop start (index + 1) acc
|
|
in
|
|
loop 0 0 Rrbvec.empty
|
|
|
|
let find_session_line text =
|
|
text |> split_lines
|
|
|> Rrbvec.find_opt (fun line ->
|
|
let lower = String.lowercase_ascii line in
|
|
contains_substring ~needle:"session" lower
|
|
|| contains_substring ~needle:"thread" lower)
|
|
|
|
let start_process_capture_session_line command args env =
|
|
let tmp_dir =
|
|
Fs.mkdtemp_sync (Node.Path.join [| Os.tmpdir (); "logseq-cli-session-" |])
|
|
in
|
|
let stdout_path = Node.Path.join [| tmp_dir; "stdout.log" |] in
|
|
let stderr_path = Node.Path.join [| tmp_dir; "stderr.log" |] in
|
|
let stdout_fd = Fs.open_sync stdout_path "a" in
|
|
let stderr_fd = Fs.open_sync stderr_path "a" in
|
|
let child =
|
|
try
|
|
Child_process.spawn command (Rrbvec.to_array args)
|
|
(Child_process.spawn_options ~detached:true
|
|
~stdio:
|
|
[|
|
|
Js.Json.string "ignore";
|
|
Js.Json.number (float_of_int stdout_fd);
|
|
Js.Json.number (float_of_int stderr_fd);
|
|
|]
|
|
~env:(copy_env env) ~shell:false ())
|
|
with exn ->
|
|
(try Fs.close_sync stdout_fd with _ -> ());
|
|
(try Fs.close_sync stderr_fd with _ -> ());
|
|
raise_error "spawn" command exn
|
|
in
|
|
(try Fs.close_sync stdout_fd with _ -> ());
|
|
(try Fs.close_sync stderr_fd with _ -> ());
|
|
let pid =
|
|
Child_process.pid child |> Js.Nullable.toOption |> Option.value ~default:0
|
|
in
|
|
let deadline = Js.Date.now () +. 30_000. in
|
|
let rec loop last_stdout =
|
|
let stdout = read_text_file_default stdout_path in
|
|
match find_session_line stdout with
|
|
| Some line ->
|
|
Child_process.unref child;
|
|
{
|
|
status = 0;
|
|
stdout = line;
|
|
stderr = read_text_file_default stderr_path;
|
|
}
|
|
| None when Js.Date.now () >= deadline ->
|
|
Child_process.unref child;
|
|
{
|
|
status = 124;
|
|
stdout = (if stdout = "" then last_stdout else stdout);
|
|
stderr =
|
|
(let stderr = read_text_file_default stderr_path in
|
|
if String.trim stderr = "" then "timed out waiting for session id"
|
|
else stderr);
|
|
}
|
|
| None when pid <= 0 || not (process_running pid) ->
|
|
{ status = 1; stdout; stderr = read_text_file_default stderr_path }
|
|
| None ->
|
|
Atomics_sleep.sleep_ms 50;
|
|
loop stdout
|
|
in
|
|
loop ""
|
|
|
|
let kill pid signal =
|
|
try Process.kill pid signal
|
|
with _ -> raise (Cli_unix_error (ESRCH, "kill", string_of_int pid))
|
|
|
|
let unquote_double_quoted value =
|
|
let len = String.length value in
|
|
if len > 1 && value.[0] = '"' && value.[len - 1] = '"' then
|
|
String.sub value 1 (len - 2)
|
|
else value
|
|
|
|
let windows_start_url_command url =
|
|
"start \"\" \"" ^ unquote_double_quoted url ^ "\""
|
|
|
|
let open_url url =
|
|
try
|
|
let platform = Node.Process.process##platform in
|
|
let command, args =
|
|
match platform with
|
|
| "darwin" -> ("open", Rrbvec.of_array [| url |])
|
|
| "linux" -> ("xdg-open", Rrbvec.of_array [| url |])
|
|
| "win32" ->
|
|
( "cmd.exe",
|
|
Rrbvec.of_array [| "/d"; "/c"; windows_start_url_command url |] )
|
|
| _ -> raise (Cli_unix_error (EPERM, "open", url))
|
|
in
|
|
ignore
|
|
((if platform = "win32" then spawn_detached_windows_verbatim
|
|
else spawn_detached)
|
|
command args (environment ())
|
|
: int);
|
|
true
|
|
with _ -> false
|
|
|
|
let write_stdout text = ignore (Process.write Process.stdout text : bool)
|