local-commander-agent 0.3.48 → 0.3.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -212
- package/README.md +216 -216
- package/bin/command-path.mjs +223 -223
- package/bin/localcommander.mjs +872 -872
- package/bin/monitor-agents.mjs +162 -162
- package/bin/monitor-context.d.mts +7 -7
- package/bin/monitor-context.mjs +65 -65
- package/bin/monitor-mcp.mjs +136 -136
- package/bin/monitor-savings.d.mts +20 -20
- package/bin/monitor-savings.mjs +71 -71
- package/bin/monitor.mjs +530 -530
- package/bin/network-package.mjs +38 -38
- package/bin/posix.mjs +84 -84
- package/package.json +57 -57
- package/runtime/JobHost.exe +0 -0
- package/runtime/LocalCommanderAgent.exe +0 -0
- package/runtime/OwnerBridge.exe +0 -0
- package/runtime/agent.mjs +29 -5
package/bin/command-path.mjs
CHANGED
|
@@ -1,223 +1,223 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
|
|
6
|
-
const SHIM_MARKER = "@rem Local Commander managed command\r\n";
|
|
7
|
-
const WRAPPER_MARKER = "# Local Commander managed command\r\n";
|
|
8
|
-
const MONITOR_WRAPPER = "localcommander-monitor.ps1";
|
|
9
|
-
const SHORTCUT_NAME = "Local Commander Monitor";
|
|
10
|
-
const CLI_FILES = ["package.json", "bin/localcommander.mjs", "bin/monitor.mjs", "bin/monitor-agents.mjs", "bin/monitor-context.mjs", "bin/monitor-mcp.mjs", "bin/monitor-savings.mjs", "bin/command-path.mjs", "bin/network-package.mjs"];
|
|
11
|
-
|
|
12
|
-
function validVersion(version) {
|
|
13
|
-
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) throw Error("PACKAGE_VERSION_INVALID");
|
|
14
|
-
return version;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function commandShim(version) {
|
|
18
|
-
return SHIM_MARKER + '@echo off\r\nsetlocal DisableDelayedExpansion\r\nnode "%~dp0..\\Versions\\' + validVersion(version) +
|
|
19
|
-
'\\bin\\localcommander.mjs" %*\r\nexit /b %errorlevel%\r\n';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Visible launcher for the Start menu shortcut. PowerShell hosts node directly so Ctrl+C
|
|
23
|
-
// ends the monitor without cmd's "Terminate batch job" prompt; the window stays open with
|
|
24
|
-
// local guidance only when the monitor could not start or ended with an error.
|
|
25
|
-
export function monitorWrapper(version) {
|
|
26
|
-
return WRAPPER_MARKER + [
|
|
27
|
-
"# 'Continue' keeps monitor stderr lines (for example unavailable snapshots) from ending the view when output is redirected.",
|
|
28
|
-
"$ErrorActionPreference = 'Continue'",
|
|
29
|
-
"try { $Host.UI.RawUI.WindowTitle = '" + SHORTCUT_NAME + "' } catch {}",
|
|
30
|
-
"$cli = [IO.Path]::Combine([IO.Path]::GetDirectoryName($PSScriptRoot), 'Versions', '" + validVersion(version) + "', 'bin', 'localcommander.mjs')",
|
|
31
|
-
"$code = 1",
|
|
32
|
-
"try {",
|
|
33
|
-
" if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) { throw 'LOCAL_COMMANDER_CLI_MISSING' }",
|
|
34
|
-
" & node $cli monitor --watch",
|
|
35
|
-
" $code = $LASTEXITCODE",
|
|
36
|
-
"} catch { Write-Host ('' + $_) }",
|
|
37
|
-
"if ($code -ne 0) {",
|
|
38
|
-
" Write-Host ''",
|
|
39
|
-
" Write-Host 'Local Commander Monitor could not start or ended with an error.'",
|
|
40
|
-
" Write-Host 'Node.js 20+ must be installed and Local Commander must be installed for this Windows user.'",
|
|
41
|
-
" Write-Host 'Check the installation in a terminal: npx local-commander-agent@latest status'",
|
|
42
|
-
" Write-Host 'Reinstall or update: npx local-commander-agent@latest update'",
|
|
43
|
-
" Write-Host 'Help: https://localcmd.app/support'",
|
|
44
|
-
" Write-Host ''",
|
|
45
|
-
" Read-Host 'Press Enter to close this window' | Out-Null",
|
|
46
|
-
"}",
|
|
47
|
-
"exit $code",
|
|
48
|
-
""
|
|
49
|
-
].join("\r\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function plainDirectory(directory) {
|
|
53
|
-
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
54
|
-
const stat = await fs.lstat(directory);
|
|
55
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("INSTALL_DIRECTORY_INVALID");
|
|
56
|
-
}
|
|
57
|
-
async function regularOrMissing(file) {
|
|
58
|
-
const stat = await fs.lstat(file).catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
59
|
-
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw Error("INSTALL_STATE_INVALID");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function installCommandFiles(packageRoot, installRoot, version) {
|
|
63
|
-
const shim = commandShim(version);
|
|
64
|
-
const runtime = path.join(installRoot, "Versions", version);
|
|
65
|
-
const bin = path.join(installRoot, "bin");
|
|
66
|
-
for (const directory of [installRoot, path.join(installRoot, "Versions"), runtime, path.join(runtime, "bin"), bin]) await plainDirectory(directory);
|
|
67
|
-
for (const file of CLI_FILES) {
|
|
68
|
-
const source = path.join(packageRoot, file), target = path.join(runtime, file);
|
|
69
|
-
await regularOrMissing(source);
|
|
70
|
-
await regularOrMissing(target);
|
|
71
|
-
if (path.resolve(source).toLowerCase() !== path.resolve(target).toLowerCase()) await fs.copyFile(source, target);
|
|
72
|
-
}
|
|
73
|
-
await writeManaged(path.join(bin, "localcommander.cmd"), SHIM_MARKER, shim);
|
|
74
|
-
await writeManaged(path.join(bin, MONITOR_WRAPPER), WRAPPER_MARKER, monitorWrapper(version));
|
|
75
|
-
return bin;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Only files that still start with their Local Commander marker are replaced; a user-owned
|
|
79
|
-
// file with the same name is left untouched and reported.
|
|
80
|
-
async function writeManaged(target, marker, content) {
|
|
81
|
-
await regularOrMissing(target);
|
|
82
|
-
const previous = await fs.readFile(target, "utf8").catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
83
|
-
if (previous !== undefined && !previous.startsWith(marker)) throw Error("COMMAND_SHIM_CONFLICT");
|
|
84
|
-
await fs.writeFile(target, content, { mode: 0o600 });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export async function removeCommandFile(installRoot) {
|
|
88
|
-
for (const directory of [installRoot, path.join(installRoot, "bin")]) {
|
|
89
|
-
const stat = await fs.lstat(directory).catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
90
|
-
if (!stat) return;
|
|
91
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("INSTALL_DIRECTORY_INVALID");
|
|
92
|
-
}
|
|
93
|
-
for (const [name, marker] of [["localcommander.cmd", SHIM_MARKER], [MONITOR_WRAPPER, WRAPPER_MARKER]]) {
|
|
94
|
-
const file = path.join(installRoot, "bin", name);
|
|
95
|
-
await regularOrMissing(file);
|
|
96
|
-
const content = await fs.readFile(file, "utf8").catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
97
|
-
if (content?.startsWith(marker)) await fs.unlink(file);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// The per-user Start menu entry targets the stable managed wrapper, so it survives version
|
|
102
|
-
// updates and npm cache removal. Ownership is recorded in the shortcut description; an
|
|
103
|
-
// unrelated shortcut with the same display name is never overwritten or removed.
|
|
104
|
-
export const START_MENU_SCRIPT = String.raw`
|
|
105
|
-
$ErrorActionPreference = 'Stop'
|
|
106
|
-
$marker = 'Local Commander managed shortcut'
|
|
107
|
-
$folderName = if ($env:LC_SHORTCUT_FOLDER) { $env:LC_SHORTCUT_FOLDER } else { 'Programs' }
|
|
108
|
-
$programs = [Environment]::GetFolderPath($folderName)
|
|
109
|
-
if (-not $programs) { throw 'START_MENU_UNAVAILABLE' }
|
|
110
|
-
$link = Join-Path $programs ($env:LC_SHORTCUT_NAME + '.lnk')
|
|
111
|
-
$shell = New-Object -ComObject WScript.Shell
|
|
112
|
-
$exists = Test-Path -LiteralPath $link
|
|
113
|
-
if ($exists) {
|
|
114
|
-
$item = Get-Item -LiteralPath $link -Force
|
|
115
|
-
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'SHORTCUT_CONFLICT' }
|
|
116
|
-
if ($shell.CreateShortcut($link).Description -ne $marker) { throw 'SHORTCUT_CONFLICT' }
|
|
117
|
-
}
|
|
118
|
-
if ($env:LC_SHORTCUT_REMOVE -eq '1') {
|
|
119
|
-
if ($exists) { Remove-Item -LiteralPath $link -Force }
|
|
120
|
-
} else {
|
|
121
|
-
$shortcut = $shell.CreateShortcut($link)
|
|
122
|
-
$shortcut.TargetPath = $env:LC_SHORTCUT_TARGET
|
|
123
|
-
$shortcut.Arguments = $env:LC_SHORTCUT_ARGUMENTS
|
|
124
|
-
$shortcut.WorkingDirectory = $env:LC_SHORTCUT_DIRECTORY
|
|
125
|
-
$shortcut.Description = $marker
|
|
126
|
-
$shortcut.WindowStyle = 1
|
|
127
|
-
$shortcut.Save()
|
|
128
|
-
}
|
|
129
|
-
Write-Output 'SHORTCUT_READY'
|
|
130
|
-
`;
|
|
131
|
-
|
|
132
|
-
function powershellPath() {
|
|
133
|
-
return path.join(process.env.SystemRoot || "C:\\Windows", "System32/WindowsPowerShell/v1.0/powershell.exe");
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function runStartMenuScript(env) {
|
|
137
|
-
const result = spawnSync(powershellPath(),
|
|
138
|
-
["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(START_MENU_SCRIPT, "utf16le").toString("base64")],
|
|
139
|
-
{ windowsHide: true, encoding: "utf8", timeout: 15_000, env: { ...process.env, LC_SHORTCUT_NAME: SHORTCUT_NAME, ...env } });
|
|
140
|
-
if (result.error) throw Error("SHORTCUT_UPDATE_FAILED");
|
|
141
|
-
if (result.status === 0 && result.stdout.trim() === "SHORTCUT_READY") return;
|
|
142
|
-
throw Error(/SHORTCUT_CONFLICT/.test(result.stderr) ? "SHORTCUT_CONFLICT" : "SHORTCUT_UPDATE_FAILED");
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function installStartMenuShortcut(bin, workingDirectory = os.homedir()) {
|
|
146
|
-
const wrapper = path.join(bin, MONITOR_WRAPPER);
|
|
147
|
-
if (!path.isAbsolute(bin) || /["\r\n\0]/.test(bin) || !path.isAbsolute(workingDirectory) || /["\r\n\0]/.test(workingDirectory)) throw Error("COMMAND_PATH_INVALID");
|
|
148
|
-
runStartMenuScript({
|
|
149
|
-
LC_SHORTCUT_REMOVE: "0",
|
|
150
|
-
LC_SHORTCUT_TARGET: powershellPath(),
|
|
151
|
-
LC_SHORTCUT_ARGUMENTS: '-NoLogo -NoProfile -ExecutionPolicy Bypass -File "' + wrapper + '"',
|
|
152
|
-
LC_SHORTCUT_DIRECTORY: workingDirectory
|
|
153
|
-
});
|
|
154
|
-
return SHORTCUT_NAME;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function removeStartMenuShortcut() {
|
|
158
|
-
runStartMenuScript({ LC_SHORTCUT_REMOVE: "1" });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// A named shortcut (Start menu + Desktop) that launches a browser already routed through the proxy.
|
|
162
|
-
export function installBrowserShortcut(name, target, args, workingDirectory = os.homedir()) {
|
|
163
|
-
if (!name || /["\r\n\0\\/]/.test(name) || !path.isAbsolute(target) || /["\r\n\0]/.test(target) || /[\r\n\0]/.test(args)) throw Error("SHORTCUT_PARAM_INVALID");
|
|
164
|
-
for (const folder of ["Programs", "Desktop"]) runStartMenuScript({
|
|
165
|
-
LC_SHORTCUT_FOLDER: folder, LC_SHORTCUT_NAME: name, LC_SHORTCUT_REMOVE: "0",
|
|
166
|
-
LC_SHORTCUT_TARGET: target, LC_SHORTCUT_ARGUMENTS: args, LC_SHORTCUT_DIRECTORY: workingDirectory
|
|
167
|
-
});
|
|
168
|
-
return name;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function removeBrowserShortcut(name) {
|
|
172
|
-
for (const folder of ["Programs", "Desktop"]) { try { runStartMenuScript({ LC_SHORTCUT_FOLDER: folder, LC_SHORTCUT_NAME: name, LC_SHORTCUT_REMOVE: "1" }); } catch { /* best effort */ } }
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Read the registry's raw value so %VARIABLE% entries and its value type survive.
|
|
176
|
-
export const USER_PATH_SCRIPT = String.raw`
|
|
177
|
-
$ErrorActionPreference = 'Stop'
|
|
178
|
-
$target = $env:LC_COMMAND_BIN
|
|
179
|
-
$remove = $env:LC_COMMAND_PATH_REMOVE -eq '1'
|
|
180
|
-
$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
|
181
|
-
try {
|
|
182
|
-
$raw = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
183
|
-
if ($raw -isnot [string]) { throw 'USER_PATH_INVALID' }
|
|
184
|
-
$kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
|
|
185
|
-
if ($key.GetValueNames() -contains 'Path') { $kind = $key.GetValueKind('Path') }
|
|
186
|
-
if ($kind -ne [Microsoft.Win32.RegistryValueKind]::String -and $kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { throw 'USER_PATH_INVALID' }
|
|
187
|
-
$entries = $raw.Split(';')
|
|
188
|
-
$kept = @($entries | Where-Object {
|
|
189
|
-
$expanded = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')).Replace('/', '\').TrimEnd('\')
|
|
190
|
-
-not [string]::Equals($expanded, $target.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)
|
|
191
|
-
})
|
|
192
|
-
$next = $raw
|
|
193
|
-
if ($remove) { $next = [string]::Join(';', $kept) }
|
|
194
|
-
elseif ($kept.Count -eq $entries.Count) {
|
|
195
|
-
$separator = if ($raw.Length -gt 0 -and -not $raw.EndsWith(';')) { ';' } else { '' }
|
|
196
|
-
$next = $raw + $separator + $target
|
|
197
|
-
}
|
|
198
|
-
if ($next.Length -gt 32766) { throw 'USER_PATH_TOO_LONG' }
|
|
199
|
-
if ($next -cne $raw) { $key.SetValue('Path', $next, $kind) }
|
|
200
|
-
} finally { $key.Dispose() }
|
|
201
|
-
Add-Type -TypeDefinition @'
|
|
202
|
-
using System;
|
|
203
|
-
using System.Runtime.InteropServices;
|
|
204
|
-
public static class LocalCommanderEnvironment {
|
|
205
|
-
[DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
206
|
-
public static extern IntPtr SendMessageTimeout(IntPtr window, uint message, IntPtr word, string value, uint flags, uint timeout, out UIntPtr result);
|
|
207
|
-
}
|
|
208
|
-
'@
|
|
209
|
-
$result = [UIntPtr]::Zero
|
|
210
|
-
[void][LocalCommanderEnvironment]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [IntPtr]::Zero, 'Environment', 2, 1000, [ref]$result)
|
|
211
|
-
Write-Output 'USER_PATH_READY'
|
|
212
|
-
`;
|
|
213
|
-
|
|
214
|
-
export function updateUserPath(bin, remove = false) {
|
|
215
|
-
if (!path.isAbsolute(bin) || /[;"\r\n\0]/.test(bin)) throw Error("COMMAND_PATH_INVALID");
|
|
216
|
-
// The settings broadcast waits up to 1 s per top-level window; a busy desktop took 19 s (2026-10-17).
|
|
217
|
-
const result = spawnSync(powershellPath(),
|
|
218
|
-
["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(USER_PATH_SCRIPT, "utf16le").toString("base64")], {
|
|
219
|
-
windowsHide: true, encoding: "utf8", timeout: 60_000,
|
|
220
|
-
env: { ...process.env, LC_COMMAND_BIN: bin, LC_COMMAND_PATH_REMOVE: remove ? "1" : "0" }
|
|
221
|
-
});
|
|
222
|
-
if (result.error || result.status !== 0 || result.stdout.trim() !== "USER_PATH_READY") throw Error("USER_PATH_UPDATE_FAILED");
|
|
223
|
-
}
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const SHIM_MARKER = "@rem Local Commander managed command\r\n";
|
|
7
|
+
const WRAPPER_MARKER = "# Local Commander managed command\r\n";
|
|
8
|
+
const MONITOR_WRAPPER = "localcommander-monitor.ps1";
|
|
9
|
+
const SHORTCUT_NAME = "Local Commander Monitor";
|
|
10
|
+
const CLI_FILES = ["package.json", "bin/localcommander.mjs", "bin/monitor.mjs", "bin/monitor-agents.mjs", "bin/monitor-context.mjs", "bin/monitor-mcp.mjs", "bin/monitor-savings.mjs", "bin/command-path.mjs", "bin/network-package.mjs"];
|
|
11
|
+
|
|
12
|
+
function validVersion(version) {
|
|
13
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) throw Error("PACKAGE_VERSION_INVALID");
|
|
14
|
+
return version;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function commandShim(version) {
|
|
18
|
+
return SHIM_MARKER + '@echo off\r\nsetlocal DisableDelayedExpansion\r\nnode "%~dp0..\\Versions\\' + validVersion(version) +
|
|
19
|
+
'\\bin\\localcommander.mjs" %*\r\nexit /b %errorlevel%\r\n';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Visible launcher for the Start menu shortcut. PowerShell hosts node directly so Ctrl+C
|
|
23
|
+
// ends the monitor without cmd's "Terminate batch job" prompt; the window stays open with
|
|
24
|
+
// local guidance only when the monitor could not start or ended with an error.
|
|
25
|
+
export function monitorWrapper(version) {
|
|
26
|
+
return WRAPPER_MARKER + [
|
|
27
|
+
"# 'Continue' keeps monitor stderr lines (for example unavailable snapshots) from ending the view when output is redirected.",
|
|
28
|
+
"$ErrorActionPreference = 'Continue'",
|
|
29
|
+
"try { $Host.UI.RawUI.WindowTitle = '" + SHORTCUT_NAME + "' } catch {}",
|
|
30
|
+
"$cli = [IO.Path]::Combine([IO.Path]::GetDirectoryName($PSScriptRoot), 'Versions', '" + validVersion(version) + "', 'bin', 'localcommander.mjs')",
|
|
31
|
+
"$code = 1",
|
|
32
|
+
"try {",
|
|
33
|
+
" if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) { throw 'LOCAL_COMMANDER_CLI_MISSING' }",
|
|
34
|
+
" & node $cli monitor --watch",
|
|
35
|
+
" $code = $LASTEXITCODE",
|
|
36
|
+
"} catch { Write-Host ('' + $_) }",
|
|
37
|
+
"if ($code -ne 0) {",
|
|
38
|
+
" Write-Host ''",
|
|
39
|
+
" Write-Host 'Local Commander Monitor could not start or ended with an error.'",
|
|
40
|
+
" Write-Host 'Node.js 20+ must be installed and Local Commander must be installed for this Windows user.'",
|
|
41
|
+
" Write-Host 'Check the installation in a terminal: npx local-commander-agent@latest status'",
|
|
42
|
+
" Write-Host 'Reinstall or update: npx local-commander-agent@latest update'",
|
|
43
|
+
" Write-Host 'Help: https://localcmd.app/support'",
|
|
44
|
+
" Write-Host ''",
|
|
45
|
+
" Read-Host 'Press Enter to close this window' | Out-Null",
|
|
46
|
+
"}",
|
|
47
|
+
"exit $code",
|
|
48
|
+
""
|
|
49
|
+
].join("\r\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function plainDirectory(directory) {
|
|
53
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
54
|
+
const stat = await fs.lstat(directory);
|
|
55
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("INSTALL_DIRECTORY_INVALID");
|
|
56
|
+
}
|
|
57
|
+
async function regularOrMissing(file) {
|
|
58
|
+
const stat = await fs.lstat(file).catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
59
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw Error("INSTALL_STATE_INVALID");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function installCommandFiles(packageRoot, installRoot, version) {
|
|
63
|
+
const shim = commandShim(version);
|
|
64
|
+
const runtime = path.join(installRoot, "Versions", version);
|
|
65
|
+
const bin = path.join(installRoot, "bin");
|
|
66
|
+
for (const directory of [installRoot, path.join(installRoot, "Versions"), runtime, path.join(runtime, "bin"), bin]) await plainDirectory(directory);
|
|
67
|
+
for (const file of CLI_FILES) {
|
|
68
|
+
const source = path.join(packageRoot, file), target = path.join(runtime, file);
|
|
69
|
+
await regularOrMissing(source);
|
|
70
|
+
await regularOrMissing(target);
|
|
71
|
+
if (path.resolve(source).toLowerCase() !== path.resolve(target).toLowerCase()) await fs.copyFile(source, target);
|
|
72
|
+
}
|
|
73
|
+
await writeManaged(path.join(bin, "localcommander.cmd"), SHIM_MARKER, shim);
|
|
74
|
+
await writeManaged(path.join(bin, MONITOR_WRAPPER), WRAPPER_MARKER, monitorWrapper(version));
|
|
75
|
+
return bin;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Only files that still start with their Local Commander marker are replaced; a user-owned
|
|
79
|
+
// file with the same name is left untouched and reported.
|
|
80
|
+
async function writeManaged(target, marker, content) {
|
|
81
|
+
await regularOrMissing(target);
|
|
82
|
+
const previous = await fs.readFile(target, "utf8").catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
83
|
+
if (previous !== undefined && !previous.startsWith(marker)) throw Error("COMMAND_SHIM_CONFLICT");
|
|
84
|
+
await fs.writeFile(target, content, { mode: 0o600 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function removeCommandFile(installRoot) {
|
|
88
|
+
for (const directory of [installRoot, path.join(installRoot, "bin")]) {
|
|
89
|
+
const stat = await fs.lstat(directory).catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
90
|
+
if (!stat) return;
|
|
91
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("INSTALL_DIRECTORY_INVALID");
|
|
92
|
+
}
|
|
93
|
+
for (const [name, marker] of [["localcommander.cmd", SHIM_MARKER], [MONITOR_WRAPPER, WRAPPER_MARKER]]) {
|
|
94
|
+
const file = path.join(installRoot, "bin", name);
|
|
95
|
+
await regularOrMissing(file);
|
|
96
|
+
const content = await fs.readFile(file, "utf8").catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
97
|
+
if (content?.startsWith(marker)) await fs.unlink(file);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The per-user Start menu entry targets the stable managed wrapper, so it survives version
|
|
102
|
+
// updates and npm cache removal. Ownership is recorded in the shortcut description; an
|
|
103
|
+
// unrelated shortcut with the same display name is never overwritten or removed.
|
|
104
|
+
export const START_MENU_SCRIPT = String.raw`
|
|
105
|
+
$ErrorActionPreference = 'Stop'
|
|
106
|
+
$marker = 'Local Commander managed shortcut'
|
|
107
|
+
$folderName = if ($env:LC_SHORTCUT_FOLDER) { $env:LC_SHORTCUT_FOLDER } else { 'Programs' }
|
|
108
|
+
$programs = [Environment]::GetFolderPath($folderName)
|
|
109
|
+
if (-not $programs) { throw 'START_MENU_UNAVAILABLE' }
|
|
110
|
+
$link = Join-Path $programs ($env:LC_SHORTCUT_NAME + '.lnk')
|
|
111
|
+
$shell = New-Object -ComObject WScript.Shell
|
|
112
|
+
$exists = Test-Path -LiteralPath $link
|
|
113
|
+
if ($exists) {
|
|
114
|
+
$item = Get-Item -LiteralPath $link -Force
|
|
115
|
+
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'SHORTCUT_CONFLICT' }
|
|
116
|
+
if ($shell.CreateShortcut($link).Description -ne $marker) { throw 'SHORTCUT_CONFLICT' }
|
|
117
|
+
}
|
|
118
|
+
if ($env:LC_SHORTCUT_REMOVE -eq '1') {
|
|
119
|
+
if ($exists) { Remove-Item -LiteralPath $link -Force }
|
|
120
|
+
} else {
|
|
121
|
+
$shortcut = $shell.CreateShortcut($link)
|
|
122
|
+
$shortcut.TargetPath = $env:LC_SHORTCUT_TARGET
|
|
123
|
+
$shortcut.Arguments = $env:LC_SHORTCUT_ARGUMENTS
|
|
124
|
+
$shortcut.WorkingDirectory = $env:LC_SHORTCUT_DIRECTORY
|
|
125
|
+
$shortcut.Description = $marker
|
|
126
|
+
$shortcut.WindowStyle = 1
|
|
127
|
+
$shortcut.Save()
|
|
128
|
+
}
|
|
129
|
+
Write-Output 'SHORTCUT_READY'
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
function powershellPath() {
|
|
133
|
+
return path.join(process.env.SystemRoot || "C:\\Windows", "System32/WindowsPowerShell/v1.0/powershell.exe");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function runStartMenuScript(env) {
|
|
137
|
+
const result = spawnSync(powershellPath(),
|
|
138
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(START_MENU_SCRIPT, "utf16le").toString("base64")],
|
|
139
|
+
{ windowsHide: true, encoding: "utf8", timeout: 15_000, env: { ...process.env, LC_SHORTCUT_NAME: SHORTCUT_NAME, ...env } });
|
|
140
|
+
if (result.error) throw Error("SHORTCUT_UPDATE_FAILED");
|
|
141
|
+
if (result.status === 0 && result.stdout.trim() === "SHORTCUT_READY") return;
|
|
142
|
+
throw Error(/SHORTCUT_CONFLICT/.test(result.stderr) ? "SHORTCUT_CONFLICT" : "SHORTCUT_UPDATE_FAILED");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function installStartMenuShortcut(bin, workingDirectory = os.homedir()) {
|
|
146
|
+
const wrapper = path.join(bin, MONITOR_WRAPPER);
|
|
147
|
+
if (!path.isAbsolute(bin) || /["\r\n\0]/.test(bin) || !path.isAbsolute(workingDirectory) || /["\r\n\0]/.test(workingDirectory)) throw Error("COMMAND_PATH_INVALID");
|
|
148
|
+
runStartMenuScript({
|
|
149
|
+
LC_SHORTCUT_REMOVE: "0",
|
|
150
|
+
LC_SHORTCUT_TARGET: powershellPath(),
|
|
151
|
+
LC_SHORTCUT_ARGUMENTS: '-NoLogo -NoProfile -ExecutionPolicy Bypass -File "' + wrapper + '"',
|
|
152
|
+
LC_SHORTCUT_DIRECTORY: workingDirectory
|
|
153
|
+
});
|
|
154
|
+
return SHORTCUT_NAME;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function removeStartMenuShortcut() {
|
|
158
|
+
runStartMenuScript({ LC_SHORTCUT_REMOVE: "1" });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A named shortcut (Start menu + Desktop) that launches a browser already routed through the proxy.
|
|
162
|
+
export function installBrowserShortcut(name, target, args, workingDirectory = os.homedir()) {
|
|
163
|
+
if (!name || /["\r\n\0\\/]/.test(name) || !path.isAbsolute(target) || /["\r\n\0]/.test(target) || /[\r\n\0]/.test(args)) throw Error("SHORTCUT_PARAM_INVALID");
|
|
164
|
+
for (const folder of ["Programs", "Desktop"]) runStartMenuScript({
|
|
165
|
+
LC_SHORTCUT_FOLDER: folder, LC_SHORTCUT_NAME: name, LC_SHORTCUT_REMOVE: "0",
|
|
166
|
+
LC_SHORTCUT_TARGET: target, LC_SHORTCUT_ARGUMENTS: args, LC_SHORTCUT_DIRECTORY: workingDirectory
|
|
167
|
+
});
|
|
168
|
+
return name;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function removeBrowserShortcut(name) {
|
|
172
|
+
for (const folder of ["Programs", "Desktop"]) { try { runStartMenuScript({ LC_SHORTCUT_FOLDER: folder, LC_SHORTCUT_NAME: name, LC_SHORTCUT_REMOVE: "1" }); } catch { /* best effort */ } }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Read the registry's raw value so %VARIABLE% entries and its value type survive.
|
|
176
|
+
export const USER_PATH_SCRIPT = String.raw`
|
|
177
|
+
$ErrorActionPreference = 'Stop'
|
|
178
|
+
$target = $env:LC_COMMAND_BIN
|
|
179
|
+
$remove = $env:LC_COMMAND_PATH_REMOVE -eq '1'
|
|
180
|
+
$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
|
181
|
+
try {
|
|
182
|
+
$raw = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
183
|
+
if ($raw -isnot [string]) { throw 'USER_PATH_INVALID' }
|
|
184
|
+
$kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
|
|
185
|
+
if ($key.GetValueNames() -contains 'Path') { $kind = $key.GetValueKind('Path') }
|
|
186
|
+
if ($kind -ne [Microsoft.Win32.RegistryValueKind]::String -and $kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { throw 'USER_PATH_INVALID' }
|
|
187
|
+
$entries = $raw.Split(';')
|
|
188
|
+
$kept = @($entries | Where-Object {
|
|
189
|
+
$expanded = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')).Replace('/', '\').TrimEnd('\')
|
|
190
|
+
-not [string]::Equals($expanded, $target.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)
|
|
191
|
+
})
|
|
192
|
+
$next = $raw
|
|
193
|
+
if ($remove) { $next = [string]::Join(';', $kept) }
|
|
194
|
+
elseif ($kept.Count -eq $entries.Count) {
|
|
195
|
+
$separator = if ($raw.Length -gt 0 -and -not $raw.EndsWith(';')) { ';' } else { '' }
|
|
196
|
+
$next = $raw + $separator + $target
|
|
197
|
+
}
|
|
198
|
+
if ($next.Length -gt 32766) { throw 'USER_PATH_TOO_LONG' }
|
|
199
|
+
if ($next -cne $raw) { $key.SetValue('Path', $next, $kind) }
|
|
200
|
+
} finally { $key.Dispose() }
|
|
201
|
+
Add-Type -TypeDefinition @'
|
|
202
|
+
using System;
|
|
203
|
+
using System.Runtime.InteropServices;
|
|
204
|
+
public static class LocalCommanderEnvironment {
|
|
205
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
206
|
+
public static extern IntPtr SendMessageTimeout(IntPtr window, uint message, IntPtr word, string value, uint flags, uint timeout, out UIntPtr result);
|
|
207
|
+
}
|
|
208
|
+
'@
|
|
209
|
+
$result = [UIntPtr]::Zero
|
|
210
|
+
[void][LocalCommanderEnvironment]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [IntPtr]::Zero, 'Environment', 2, 1000, [ref]$result)
|
|
211
|
+
Write-Output 'USER_PATH_READY'
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
export function updateUserPath(bin, remove = false) {
|
|
215
|
+
if (!path.isAbsolute(bin) || /[;"\r\n\0]/.test(bin)) throw Error("COMMAND_PATH_INVALID");
|
|
216
|
+
// The settings broadcast waits up to 1 s per top-level window; a busy desktop took 19 s (2026-10-17).
|
|
217
|
+
const result = spawnSync(powershellPath(),
|
|
218
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(USER_PATH_SCRIPT, "utf16le").toString("base64")], {
|
|
219
|
+
windowsHide: true, encoding: "utf8", timeout: 60_000,
|
|
220
|
+
env: { ...process.env, LC_COMMAND_BIN: bin, LC_COMMAND_PATH_REMOVE: remove ? "1" : "0" }
|
|
221
|
+
});
|
|
222
|
+
if (result.error || result.status !== 0 || result.stdout.trim() !== "USER_PATH_READY") throw Error("USER_PATH_UPDATE_FAILED");
|
|
223
|
+
}
|