local-commander-agent 0.3.2
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/README.md +44 -0
- package/bin/localcommander.mjs +425 -0
- package/package.json +41 -0
- package/runtime/JobHost.exe +0 -0
- package/runtime/LocalCommanderAgent.exe +0 -0
- package/runtime/NODE-LICENSE.txt +2641 -0
- package/runtime/OwnerBridge.exe +0 -0
- package/runtime/agent.mjs +29243 -0
- package/runtime/node.exe +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Local Commander Agent
|
|
2
|
+
|
|
3
|
+
Windows 11 x64 bootstrap package for Local Commander.
|
|
4
|
+
|
|
5
|
+
## Install and pair
|
|
6
|
+
|
|
7
|
+
```powershell
|
|
8
|
+
npx local-commander-agent@latest
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The bootstrap installs the self-contained Local Commander runtime under the current user's `%LOCALAPPDATA%LocalCommander` directory, opens the browser pairing flow, lets the user choose a workspace, enables per-user autostart and starts the background agent.
|
|
12
|
+
|
|
13
|
+
Node.js 20+ is required only to run npm/npx. The installed agent uses its bundled Node runtime.
|
|
14
|
+
|
|
15
|
+
## Management
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
npx local-commander-agent@latest status
|
|
19
|
+
npx local-commander-agent@latest start
|
|
20
|
+
npx local-commander-agent@latest stop
|
|
21
|
+
npx local-commander-agent@latest restart
|
|
22
|
+
npx local-commander-agent@latest update
|
|
23
|
+
npx local-commander-agent@latest approve-workspace
|
|
24
|
+
npx local-commander-agent@latest uninstall
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
To also remove the protected Local Commander enrollment/state during uninstall:
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
npx local-commander-agent@latest uninstall --purge-state
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Uninstall never removes user project files.
|
|
34
|
+
|
|
35
|
+
## Control plane
|
|
36
|
+
|
|
37
|
+
The package defaults to `https://localcmd.cloudpub.ru`. A staging or self-hosted control plane can be selected only at first pairing with:
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
$env:LC_CONTROL_PLANE_ORIGIN="https://your-control-plane.example"
|
|
41
|
+
npx local-commander-agent@latest
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The enrollment stores the selected origin for later relay connections.
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const PACKAGE_META = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
11
|
+
const VERSION = String(PACKAGE_META.version || "");
|
|
12
|
+
const DEFAULT_ORIGIN = String(PACKAGE_META.localCommander?.controlPlaneOrigin || "");
|
|
13
|
+
const RUNTIME_FILES = ["LocalCommanderAgent.exe", "OwnerBridge.exe", "JobHost.exe", "agent.mjs", "node.exe", "NODE-LICENSE.txt"];
|
|
14
|
+
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
15
|
+
const RUN_VALUE = "LocalCommanderAgent";
|
|
16
|
+
const MAX_STDIO = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
function fail(code) {
|
|
19
|
+
const error = new Error(code);
|
|
20
|
+
error.code = code;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
function requireWindows() {
|
|
24
|
+
if (process.platform !== "win32" || process.arch !== "x64") fail("WINDOWS_X64_REQUIRED");
|
|
25
|
+
}
|
|
26
|
+
function appDataRoot() {
|
|
27
|
+
const value = process.env.LOCALAPPDATA;
|
|
28
|
+
if (!value || !path.isAbsolute(value) || /[\u0000-\u001f]/u.test(value)) fail("APP_DATA_MISSING");
|
|
29
|
+
return path.join(value, "LocalCommander");
|
|
30
|
+
}
|
|
31
|
+
function stateDirectory() {
|
|
32
|
+
const override = process.env.LC_AGENT_STATE_DIR;
|
|
33
|
+
if (override) {
|
|
34
|
+
if (!path.isAbsolute(override) || /[\u0000-\u001f]/u.test(override)) fail("STATE_DIR_INVALID");
|
|
35
|
+
return path.resolve(override);
|
|
36
|
+
}
|
|
37
|
+
return path.join(appDataRoot(), "Agent");
|
|
38
|
+
}
|
|
39
|
+
function versionsDirectory() {
|
|
40
|
+
return path.join(appDataRoot(), "Versions");
|
|
41
|
+
}
|
|
42
|
+
function currentPath() {
|
|
43
|
+
return path.join(appDataRoot(), "current.json");
|
|
44
|
+
}
|
|
45
|
+
function runtimeSource() {
|
|
46
|
+
return path.join(PACKAGE_ROOT, "runtime");
|
|
47
|
+
}
|
|
48
|
+
function processFile() {
|
|
49
|
+
return path.join(stateDirectory(), "agent-process.json");
|
|
50
|
+
}
|
|
51
|
+
function under(parent, child) {
|
|
52
|
+
const rel = path.relative(parent, child);
|
|
53
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
54
|
+
}
|
|
55
|
+
function boundedText(value) {
|
|
56
|
+
const text = String(value ?? "");
|
|
57
|
+
return text.length > MAX_STDIO ? text.slice(0, MAX_STDIO) : text;
|
|
58
|
+
}
|
|
59
|
+
async function regular(file) {
|
|
60
|
+
const stat = await fs.lstat(file);
|
|
61
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 1) fail("PACKAGE_RUNTIME_INVALID");
|
|
62
|
+
return stat;
|
|
63
|
+
}
|
|
64
|
+
async function ensurePlainDirectory(dir) {
|
|
65
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
const stat = await fs.lstat(dir);
|
|
67
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail("INSTALL_DIRECTORY_INVALID");
|
|
68
|
+
return dir;
|
|
69
|
+
}
|
|
70
|
+
async function ensureInstallDirectories() {
|
|
71
|
+
const root = await ensurePlainDirectory(appDataRoot());
|
|
72
|
+
const versions = await ensurePlainDirectory(versionsDirectory());
|
|
73
|
+
if (!under(root, versions)) fail("INSTALL_DIRECTORY_INVALID");
|
|
74
|
+
return { root, versions };
|
|
75
|
+
}
|
|
76
|
+
async function runtimeValid(dir) {
|
|
77
|
+
try {
|
|
78
|
+
for (const name of RUNTIME_FILES) await regular(path.join(dir, name));
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function writeJsonAtomic(file, value) {
|
|
85
|
+
await ensurePlainDirectory(path.dirname(file));
|
|
86
|
+
const existing = await fs.lstat(file).catch(error => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
87
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink())) fail("INSTALL_STATE_INVALID");
|
|
88
|
+
const temp = file + "." + process.pid + "." + randomBytes(6).toString("hex") + ".tmp";
|
|
89
|
+
await fs.writeFile(temp, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
90
|
+
try {
|
|
91
|
+
await fs.rename(temp, file);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
await fs.rm(temp, { force: true });
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function readCurrent() {
|
|
98
|
+
try {
|
|
99
|
+
const current = currentPath();
|
|
100
|
+
const stat = await fs.lstat(current);
|
|
101
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail("INSTALL_STATE_INVALID");
|
|
102
|
+
const raw = JSON.parse(await fs.readFile(current, "utf8"));
|
|
103
|
+
if (!raw || typeof raw !== "object" || typeof raw.version !== "string" || typeof raw.runtimeDir !== "string") fail("INSTALL_STATE_INVALID");
|
|
104
|
+
const dir = path.resolve(raw.runtimeDir);
|
|
105
|
+
if (!under(versionsDirectory(), dir) || !(await runtimeValid(dir))) fail("INSTALL_STATE_INVALID");
|
|
106
|
+
return { version: raw.version, runtimeDir: dir };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error && error.code === "ENOENT") return null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function copyRuntime() {
|
|
113
|
+
const source = runtimeSource();
|
|
114
|
+
if (!(await runtimeValid(source))) fail("PACKAGE_RUNTIME_MISSING");
|
|
115
|
+
const { versions } = await ensureInstallDirectories();
|
|
116
|
+
const target = path.join(versions, VERSION);
|
|
117
|
+
if (await runtimeValid(target)) {
|
|
118
|
+
await writeJsonAtomic(currentPath(), { version: VERSION, runtimeDir: target });
|
|
119
|
+
return target;
|
|
120
|
+
}
|
|
121
|
+
const targetStat = await fs.lstat(target).catch(error => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
122
|
+
if (targetStat) {
|
|
123
|
+
if (!targetStat.isDirectory() || targetStat.isSymbolicLink() || !under(versions, target)) fail("INSTALL_TARGET_INVALID");
|
|
124
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
const temp = path.join(versions, "." + VERSION + "." + process.pid + "." + randomBytes(6).toString("hex"));
|
|
127
|
+
await fs.mkdir(temp, { recursive: false, mode: 0o700 });
|
|
128
|
+
try {
|
|
129
|
+
for (const name of RUNTIME_FILES) {
|
|
130
|
+
const sourceFile = path.join(source, name);
|
|
131
|
+
const sourceStat = await regular(sourceFile);
|
|
132
|
+
const destination = path.join(temp, name);
|
|
133
|
+
await fs.copyFile(sourceFile, destination, fsConstants.COPYFILE_EXCL);
|
|
134
|
+
const copied = await regular(destination);
|
|
135
|
+
if (copied.size !== sourceStat.size) fail("PACKAGE_COPY_INVALID");
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await fs.rename(temp, target);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(await runtimeValid(target))) throw error;
|
|
141
|
+
await fs.rm(temp, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
await writeJsonAtomic(currentPath(), { version: VERSION, runtimeDir: target });
|
|
144
|
+
return target;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await fs.rm(temp, { recursive: true, force: true });
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function runReg(args, allowMissing = false) {
|
|
151
|
+
const result = spawnSync("reg.exe", args, { windowsHide: true, encoding: "utf8", timeout: 10_000 });
|
|
152
|
+
if (result.error) fail("AUTOSTART_COMMAND_FAILED");
|
|
153
|
+
if (result.status !== 0 && !(allowMissing && result.status === 1)) fail("AUTOSTART_COMMAND_FAILED");
|
|
154
|
+
}
|
|
155
|
+
function registerAutostart(runtimeDir) {
|
|
156
|
+
const exe = path.join(runtimeDir, "LocalCommanderAgent.exe");
|
|
157
|
+
if (exe.includes('"')) fail("INSTALL_PATH_INVALID");
|
|
158
|
+
runReg(["ADD", RUN_KEY, "/v", RUN_VALUE, "/t", "REG_SZ", "/d", '"' + exe + '" run', "/f"]);
|
|
159
|
+
}
|
|
160
|
+
function removeAutostart() {
|
|
161
|
+
runReg(["DELETE", RUN_KEY, "/v", RUN_VALUE, "/f"], true);
|
|
162
|
+
}
|
|
163
|
+
function agentEnv() {
|
|
164
|
+
const env = { ...process.env, LC_AGENT_STATE_DIR: stateDirectory() };
|
|
165
|
+
delete env.NODE_OPTIONS;
|
|
166
|
+
delete env.NODE_PATH;
|
|
167
|
+
return env;
|
|
168
|
+
}
|
|
169
|
+
function agentExe(runtimeDir) {
|
|
170
|
+
return path.join(runtimeDir, "LocalCommanderAgent.exe");
|
|
171
|
+
}
|
|
172
|
+
function execAgent(runtimeDir, args, timeout = 30_000) {
|
|
173
|
+
const result = spawnSync(agentExe(runtimeDir), args, {
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
timeout,
|
|
177
|
+
env: agentEnv()
|
|
178
|
+
});
|
|
179
|
+
if (result.error) fail("AGENT_COMMAND_FAILED");
|
|
180
|
+
return { status: result.status ?? -1, stdout: boundedText(result.stdout), stderr: boundedText(result.stderr) };
|
|
181
|
+
}
|
|
182
|
+
function openBrowser(url) {
|
|
183
|
+
let parsed;
|
|
184
|
+
try { parsed = new URL(url); } catch { fail("PAIRING_URL_INVALID"); }
|
|
185
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname))) fail("PAIRING_URL_INVALID");
|
|
186
|
+
const child = spawn("rundll32.exe", ["url.dll,FileProtocolHandler", parsed.toString()], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
187
|
+
child.unref();
|
|
188
|
+
}
|
|
189
|
+
async function identityStatus(runtimeDir) {
|
|
190
|
+
const result = execAgent(runtimeDir, ["identity-status"], 15_000);
|
|
191
|
+
if (result.status !== 0) fail(result.stderr.trim() || "IDENTITY_STATUS_FAILED");
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
194
|
+
return { paired: parsed.paired === true, accountEmail: typeof parsed.accountEmail === "string" ? parsed.accountEmail : null, deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : null };
|
|
195
|
+
} catch { fail("IDENTITY_STATUS_INVALID"); }
|
|
196
|
+
}
|
|
197
|
+
async function pair(runtimeDir) {
|
|
198
|
+
const origin = process.env.LC_CONTROL_PLANE_ORIGIN || DEFAULT_ORIGIN;
|
|
199
|
+
if (!origin) fail("CONTROL_PLANE_ORIGIN_MISSING");
|
|
200
|
+
const child = spawn(agentExe(runtimeDir), ["pair"], {
|
|
201
|
+
windowsHide: true,
|
|
202
|
+
env: { ...agentEnv(), LC_CONTROL_PLANE_ORIGIN: origin },
|
|
203
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
204
|
+
});
|
|
205
|
+
let stdout = "", stderr = "", opened = false;
|
|
206
|
+
child.stdout.setEncoding("utf8");
|
|
207
|
+
child.stderr.setEncoding("utf8");
|
|
208
|
+
child.stdout.on("data", chunk => {
|
|
209
|
+
stdout = boundedText(stdout + chunk);
|
|
210
|
+
if (opened) return;
|
|
211
|
+
const lineEnd = stdout.indexOf("\n");
|
|
212
|
+
if (lineEnd < 0) return;
|
|
213
|
+
const line = stdout.slice(0, lineEnd).trim();
|
|
214
|
+
try {
|
|
215
|
+
const begin = JSON.parse(line);
|
|
216
|
+
if (typeof begin.approvalUrl !== "string" || typeof begin.confirmationCode !== "string") fail("PAIRING_RESPONSE_INVALID");
|
|
217
|
+
opened = true;
|
|
218
|
+
process.stdout.write("Pairing code: " + begin.confirmationCode + "\n");
|
|
219
|
+
process.stdout.write("Opening: " + begin.approvalUrl + "\n");
|
|
220
|
+
openBrowser(begin.approvalUrl);
|
|
221
|
+
} catch {
|
|
222
|
+
child.kill();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
child.stderr.on("data", chunk => { stderr = boundedText(stderr + chunk); });
|
|
226
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
227
|
+
child.once("error", reject);
|
|
228
|
+
child.once("exit", code => resolve(code ?? -1));
|
|
229
|
+
}).catch(() => -1);
|
|
230
|
+
if (exitCode !== 0) fail(stderr.trim() || "PAIRING_FAILED");
|
|
231
|
+
if (!opened) fail("PAIRING_RESPONSE_INVALID");
|
|
232
|
+
process.stdout.write("Device paired.\n");
|
|
233
|
+
}
|
|
234
|
+
async function approveWorkspace(runtimeDir) {
|
|
235
|
+
const result = execAgent(runtimeDir, ["approve-workspace"], 180_000);
|
|
236
|
+
if (result.status !== 0) fail(result.stderr.trim() || "WORKSPACE_APPROVAL_FAILED");
|
|
237
|
+
process.stdout.write(result.stdout || "Workspace selection finished.\n");
|
|
238
|
+
}
|
|
239
|
+
function pidAlive(pid) {
|
|
240
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
241
|
+
try { process.kill(pid, 0); return true; }
|
|
242
|
+
catch (error) { return error && error.code === "EPERM"; }
|
|
243
|
+
}
|
|
244
|
+
async function readProcessRecord() {
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(await fs.readFile(processFile(), "utf8"));
|
|
247
|
+
if (!parsed || typeof parsed !== "object" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.runtimeDir !== "string") return null;
|
|
248
|
+
const runtimeDir = path.resolve(parsed.runtimeDir);
|
|
249
|
+
if (!under(versionsDirectory(), runtimeDir)) return null;
|
|
250
|
+
return { pid: parsed.pid, runtimeDir, version: typeof parsed.version === "string" ? parsed.version : null, startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null };
|
|
251
|
+
} catch { return null; }
|
|
252
|
+
}
|
|
253
|
+
async function statusObject() {
|
|
254
|
+
const current = await readCurrent();
|
|
255
|
+
const record = await readProcessRecord();
|
|
256
|
+
const running = !!record && pidAlive(record.pid);
|
|
257
|
+
if (record && !running) await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
258
|
+
const identity = current ? await identityStatus(current.runtimeDir).catch(() => ({ paired: false, accountEmail: null, deviceId: null })) : { paired: false, accountEmail: null, deviceId: null };
|
|
259
|
+
return {
|
|
260
|
+
installed: !!current,
|
|
261
|
+
version: current?.version ?? null,
|
|
262
|
+
running,
|
|
263
|
+
pid: running ? record.pid : null,
|
|
264
|
+
paired: identity.paired,
|
|
265
|
+
accountEmail: identity.accountEmail,
|
|
266
|
+
deviceId: identity.deviceId,
|
|
267
|
+
stateDirectory: stateDirectory()
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async function verifyOwnedProcess(record) {
|
|
271
|
+
if (!pidAlive(record.pid)) return false;
|
|
272
|
+
const script = '$p=Get-CimInstance Win32_Process -Filter "ProcessId = ' + record.pid + '"; if($null -eq $p){exit 3}; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Write-Output $p.ExecutablePath; Write-Output $p.CommandLine';
|
|
273
|
+
const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, encoding: "utf8", timeout: 10_000 });
|
|
274
|
+
if (result.status === 3) return false;
|
|
275
|
+
if (result.status !== 0) fail("PROCESS_IDENTITY_CHECK_FAILED");
|
|
276
|
+
const lines = String(result.stdout || "").split(/\r?\n/u).filter(Boolean);
|
|
277
|
+
const executable = path.resolve(lines.shift() || "");
|
|
278
|
+
const expectedNode = path.resolve(record.runtimeDir, "node.exe");
|
|
279
|
+
const commandLine = lines.join("\n").toLowerCase();
|
|
280
|
+
return executable.toLowerCase() === expectedNode.toLowerCase() && commandLine.includes(path.join(record.runtimeDir, "agent.mjs").toLowerCase());
|
|
281
|
+
}
|
|
282
|
+
async function stopAgent() {
|
|
283
|
+
const record = await readProcessRecord();
|
|
284
|
+
if (!record || !pidAlive(record.pid)) {
|
|
285
|
+
await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
if (!(await verifyOwnedProcess(record))) fail("PROCESS_IDENTITY_MISMATCH");
|
|
289
|
+
const result = spawnSync("taskkill.exe", ["/PID", String(record.pid), "/T", "/F"], { windowsHide: true, encoding: "utf8", timeout: 15_000 });
|
|
290
|
+
if (result.status !== 0 && pidAlive(record.pid)) fail("AGENT_STOP_FAILED");
|
|
291
|
+
for (let i = 0; i < 20 && pidAlive(record.pid); i += 1) await new Promise(resolve => setTimeout(resolve, 100));
|
|
292
|
+
if (pidAlive(record.pid)) fail("AGENT_STOP_TIMEOUT");
|
|
293
|
+
await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
async function startAgent() {
|
|
297
|
+
const current = await readCurrent();
|
|
298
|
+
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
299
|
+
const identity = await identityStatus(current.runtimeDir);
|
|
300
|
+
if (!identity.paired) fail("ENROLLMENT_MISSING");
|
|
301
|
+
const status = await statusObject();
|
|
302
|
+
if (status.running) return false;
|
|
303
|
+
const child = spawn(agentExe(current.runtimeDir), ["run"], { detached: true, windowsHide: true, stdio: "ignore", env: agentEnv() });
|
|
304
|
+
child.unref();
|
|
305
|
+
for (let i = 0; i < 40; i += 1) {
|
|
306
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
307
|
+
const record = await readProcessRecord();
|
|
308
|
+
if (record && pidAlive(record.pid)) return true;
|
|
309
|
+
}
|
|
310
|
+
fail("AGENT_START_TIMEOUT");
|
|
311
|
+
}
|
|
312
|
+
async function bootstrap({ chooseWorkspace = true } = {}) {
|
|
313
|
+
const previous = await readCurrent();
|
|
314
|
+
const target = path.join(versionsDirectory(), VERSION);
|
|
315
|
+
const running = await readProcessRecord();
|
|
316
|
+
if (running && pidAlive(running.pid) && path.resolve(running.runtimeDir).toLowerCase() !== path.resolve(target).toLowerCase()) await stopAgent();
|
|
317
|
+
const runtimeDir = await copyRuntime();
|
|
318
|
+
const identity = await identityStatus(runtimeDir);
|
|
319
|
+
if (!identity.paired) await pair(runtimeDir);
|
|
320
|
+
if (chooseWorkspace) {
|
|
321
|
+
const list = execAgent(runtimeDir, ["list-workspaces"], 15_000);
|
|
322
|
+
let count = 0;
|
|
323
|
+
if (list.status === 0) {
|
|
324
|
+
try { const parsed = JSON.parse(list.stdout || "[]"); count = Array.isArray(parsed) ? parsed.length : 0; } catch {}
|
|
325
|
+
}
|
|
326
|
+
if (count === 0) await approveWorkspace(runtimeDir);
|
|
327
|
+
}
|
|
328
|
+
registerAutostart(runtimeDir);
|
|
329
|
+
await startAgent();
|
|
330
|
+
if (previous && previous.version !== VERSION) process.stdout.write("Updated Local Commander from " + previous.version + " to " + VERSION + ".\n");
|
|
331
|
+
process.stdout.write("Local Commander " + VERSION + " is installed and running.\n");
|
|
332
|
+
}
|
|
333
|
+
async function uninstall(purgeState) {
|
|
334
|
+
await stopAgent();
|
|
335
|
+
removeAutostart();
|
|
336
|
+
const versions = versionsDirectory();
|
|
337
|
+
if (under(appDataRoot(), versions)) await fs.rm(versions, { recursive: true, force: true });
|
|
338
|
+
await fs.rm(currentPath(), { force: true });
|
|
339
|
+
if (purgeState) {
|
|
340
|
+
const state = stateDirectory();
|
|
341
|
+
const expected = path.join(appDataRoot(), "Agent");
|
|
342
|
+
if (path.resolve(state).toLowerCase() !== path.resolve(expected).toLowerCase()) fail("CUSTOM_STATE_PURGE_REFUSED");
|
|
343
|
+
await fs.rm(state, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
process.stdout.write(purgeState ? "Local Commander removed, including local enrollment state.\n" : "Local Commander removed. Protected enrollment state was preserved.\n");
|
|
346
|
+
}
|
|
347
|
+
function help() {
|
|
348
|
+
return [
|
|
349
|
+
"Local Commander agent " + VERSION,
|
|
350
|
+
"",
|
|
351
|
+
"Usage:",
|
|
352
|
+
" npx local-commander-agent@latest install/pair/start",
|
|
353
|
+
" npx local-commander-agent@latest status show local state",
|
|
354
|
+
" npx local-commander-agent@latest start start background agent",
|
|
355
|
+
" npx local-commander-agent@latest stop stop background agent",
|
|
356
|
+
" npx local-commander-agent@latest restart restart background agent",
|
|
357
|
+
" npx local-commander-agent@latest update install this package version and restart",
|
|
358
|
+
" npx local-commander-agent@latest approve-workspace",
|
|
359
|
+
" npx local-commander-agent@latest uninstall [--purge-state]",
|
|
360
|
+
""
|
|
361
|
+
].join("\n");
|
|
362
|
+
}
|
|
363
|
+
async function main() {
|
|
364
|
+
requireWindows();
|
|
365
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(VERSION)) fail("PACKAGE_VERSION_INVALID");
|
|
366
|
+
const command = process.argv[2] || "bootstrap";
|
|
367
|
+
const args = process.argv.slice(3);
|
|
368
|
+
if (command === "-h" || command === "--help" || command === "help") {
|
|
369
|
+
process.stdout.write(help());
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (command === "bootstrap" || command === "install") {
|
|
373
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
374
|
+
await bootstrap();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (command === "update") {
|
|
378
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
379
|
+
await bootstrap({ chooseWorkspace: false });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (command === "start") {
|
|
383
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
384
|
+
await startAgent();
|
|
385
|
+
process.stdout.write("Local Commander is running.\n");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (command === "stop") {
|
|
389
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
390
|
+
const stopped = await stopAgent();
|
|
391
|
+
process.stdout.write(stopped ? "Local Commander stopped.\n" : "Local Commander was not running.\n");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (command === "restart") {
|
|
395
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
396
|
+
await stopAgent();
|
|
397
|
+
await startAgent();
|
|
398
|
+
process.stdout.write("Local Commander restarted.\n");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (command === "status") {
|
|
402
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
403
|
+
process.stdout.write(JSON.stringify(await statusObject(), null, 2) + "\n");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (command === "approve-workspace") {
|
|
407
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
408
|
+
const current = await readCurrent();
|
|
409
|
+
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
410
|
+
await approveWorkspace(current.runtimeDir);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (command === "uninstall") {
|
|
414
|
+
if (args.length > 1 || (args.length === 1 && args[0] !== "--purge-state")) fail("ARGUMENT_INVALID");
|
|
415
|
+
await uninstall(args[0] === "--purge-state");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
fail("ARGUMENT_INVALID");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
void main().catch(error => {
|
|
422
|
+
const code = error && typeof error.message === "string" && /^[A-Z0-9_]{1,80}$/u.test(error.message) ? error.message : "LOCAL_COMMANDER_FAILED";
|
|
423
|
+
process.stderr.write(code + "\n");
|
|
424
|
+
process.exitCode = 1;
|
|
425
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "local-commander-agent",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "One-command Windows bootstrap for the Local Commander agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"localcommander": "bin/localcommander.mjs"
|
|
8
|
+
},
|
|
9
|
+
"os": [
|
|
10
|
+
"win32"
|
|
11
|
+
],
|
|
12
|
+
"cpu": [
|
|
13
|
+
"x64"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.0.0"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/",
|
|
20
|
+
"runtime/",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"prepack": "node ../../scripts/build-npm-agent.mjs",
|
|
25
|
+
"prepublishOnly": "node ../../scripts/verify-npm-agent.mjs"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"mcp",
|
|
32
|
+
"local",
|
|
33
|
+
"agent",
|
|
34
|
+
"chatgpt",
|
|
35
|
+
"claude",
|
|
36
|
+
"windows"
|
|
37
|
+
],
|
|
38
|
+
"localCommander": {
|
|
39
|
+
"controlPlaneOrigin": "https://localcmd.cloudpub.ru"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
Binary file
|
|
Binary file
|