@longleash/cli 0.1.0-rc.1

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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/bin/longleash.mjs +821 -0
  5. package/npm-shrinkwrap.json +2787 -0
  6. package/package.json +77 -0
  7. package/runtime/app/dist/_headers +15 -0
  8. package/runtime/app/dist/apple-touch-icon.png +0 -0
  9. package/runtime/app/dist/assets/app-CxamvvoS.js +27 -0
  10. package/runtime/app/dist/assets/styles-C-1gzhnT.js +9 -0
  11. package/runtime/app/dist/assets/styles-CkAMG7sK.css +1 -0
  12. package/runtime/app/dist/assets/welcome-CG_rEa--.js +27 -0
  13. package/runtime/app/dist/assets/welcome-ai7kxCj7.css +1 -0
  14. package/runtime/app/dist/build.json +1 -0
  15. package/runtime/app/dist/favicon.png +0 -0
  16. package/runtime/app/dist/fonts/geist-mono-latin.woff2 +0 -0
  17. package/runtime/app/dist/fonts/instrument-sans-latin.woff2 +0 -0
  18. package/runtime/app/dist/fonts/instrument-serif-latin.woff2 +0 -0
  19. package/runtime/app/dist/icon-192.png +0 -0
  20. package/runtime/app/dist/icon-512.png +0 -0
  21. package/runtime/app/dist/index.html +37 -0
  22. package/runtime/app/dist/manifest.webmanifest +23 -0
  23. package/runtime/app/dist/robots.txt +2 -0
  24. package/runtime/app/dist/sw.js +138 -0
  25. package/runtime/app/dist/welcome.html +44 -0
  26. package/runtime/daemon/bin/longleash-devices.mjs +203 -0
  27. package/runtime/daemon/bin/longleashd.mjs +12845 -0
  28. package/runtime/daemon/hooks/agent-pid.mjs +35 -0
  29. package/runtime/daemon/hooks/install-codex-hooks.mjs +191 -0
  30. package/runtime/daemon/hooks/install-hooks.mjs +78 -0
  31. package/runtime/daemon/hooks/local-handoff.mjs +47 -0
  32. package/runtime/daemon/hooks/longleash-codex-hook.mjs +118 -0
  33. package/runtime/daemon/hooks/longleash-hook.mjs +148 -0
  34. package/runtime/daemon/hooks/surface.mjs +41 -0
@@ -0,0 +1,821 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
5
+ import { homedir as homedir3 } from "node:os";
6
+ import { delimiter, dirname as dirname3, join as join3, resolve as resolve3 } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawn, spawnSync as spawnSync2 } from "node:child_process";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { stdin as input, stdout as output } from "node:process";
11
+
12
+ // src/config.ts
13
+ import {
14
+ closeSync,
15
+ fsyncSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ openSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ renameSync,
22
+ rmSync,
23
+ statSync,
24
+ unlinkSync,
25
+ writeFileSync
26
+ } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { dirname, join, resolve } from "node:path";
29
+ import { randomUUID } from "node:crypto";
30
+ var HOSTED_RELAY = "wss://app.longleash.dev/ws";
31
+ function dataDir(env = process.env) {
32
+ return resolve(env.LONGLEASH_DATA ?? join(homedir(), ".longleash"));
33
+ }
34
+ function configPath(env = process.env) {
35
+ return join(dataDir(env), "config.json");
36
+ }
37
+ function loadConfig(path = configPath()) {
38
+ if (!pathExists(path)) return {};
39
+ if (lstatSync(path).isSymbolicLink()) {
40
+ throw new Error(`Refusing to read symlinked configuration: ${path}`);
41
+ }
42
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
43
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
44
+ throw new Error(`Configuration is not a JSON object: ${path}`);
45
+ }
46
+ return parsed;
47
+ }
48
+ function normalizeRoots(values) {
49
+ const roots = [...new Set(values.map((value) => realpathSync(resolve(value))))];
50
+ if (roots.length === 0) throw new Error("At least one allowed project directory is required.");
51
+ for (const root of roots) {
52
+ if (!lstatSync(root).isDirectory()) throw new Error(`Allowed root is not a directory: ${root}`);
53
+ if (root === "/") throw new Error("Refusing to allow the entire filesystem. Choose project folders instead.");
54
+ }
55
+ return roots;
56
+ }
57
+ function normalizeRelay(value) {
58
+ const normalized = value.trim();
59
+ if (normalized === "" || normalized === "hosted") return HOSTED_RELAY;
60
+ if (normalized.toLowerCase() === "off" || normalized.toLowerCase() === "lan") return null;
61
+ const url = new URL(normalized);
62
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
63
+ if (url.protocol !== "wss:" && !(url.protocol === "ws:" && loopback)) {
64
+ throw new Error("Relay URLs must use wss://. Plain ws:// is accepted only for loopback development.");
65
+ }
66
+ url.hash = "";
67
+ url.search = "";
68
+ if (url.pathname === "/" || url.pathname === "") url.pathname = "/ws";
69
+ if (url.pathname !== "/ws") throw new Error("Relay URL path must be /ws.");
70
+ return url.toString().replace(/\/$/, "");
71
+ }
72
+ function saveConfig(next, path = configPath()) {
73
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
74
+ if (pathExists(path) && lstatSync(path).isSymbolicLink()) {
75
+ throw new Error(`Refusing to overwrite symlinked configuration: ${path}`);
76
+ }
77
+ const temp = createTemporaryFile(path, `${JSON.stringify(next, null, 2)}
78
+ `, 384, "tmp");
79
+ try {
80
+ if (pathExists(path)) {
81
+ const backup = `${path}.previous`;
82
+ if (pathExists(backup) && lstatSync(backup).isSymbolicLink()) {
83
+ throw new Error(`Refusing to overwrite symlinked configuration backup: ${backup}`);
84
+ }
85
+ writeBytesAtomically(backup, readFileSync(path), statSync(path).mode & 511);
86
+ }
87
+ renameSync(temp, path);
88
+ } catch (error) {
89
+ try {
90
+ unlinkSync(temp);
91
+ } catch {
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ function captureConfigSnapshot(path = configPath()) {
97
+ return {
98
+ path,
99
+ config: captureFile(path),
100
+ previous: captureFile(`${path}.previous`)
101
+ };
102
+ }
103
+ function restoreConfigSnapshot(snapshot) {
104
+ const previous = `${snapshot.path}.previous`;
105
+ assertNotSymlink(snapshot.path);
106
+ assertNotSymlink(previous);
107
+ restoreFile(previous, snapshot.previous);
108
+ restoreFile(snapshot.path, snapshot.config);
109
+ }
110
+ function configuredRoots(config) {
111
+ return Array.isArray(config.allowedRoots) ? config.allowedRoots.filter((root) => typeof root === "string" && root !== "") : [];
112
+ }
113
+ function captureFile(path) {
114
+ if (!pathExists(path)) return { content: null, mode: 384 };
115
+ assertNotSymlink(path);
116
+ return { content: readFileSync(path), mode: statSync(path).mode & 511 };
117
+ }
118
+ function assertNotSymlink(path) {
119
+ if (pathExists(path) && lstatSync(path).isSymbolicLink()) {
120
+ throw new Error(`Refusing symlinked configuration transaction path: ${path}`);
121
+ }
122
+ }
123
+ function pathExists(path) {
124
+ try {
125
+ lstatSync(path);
126
+ return true;
127
+ } catch (error) {
128
+ if (error.code === "ENOENT") return false;
129
+ throw error;
130
+ }
131
+ }
132
+ function restoreFile(path, snapshot) {
133
+ if (snapshot.content === null) {
134
+ rmSync(path, { force: true });
135
+ return;
136
+ }
137
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
138
+ writeBytesAtomically(path, snapshot.content, snapshot.mode);
139
+ }
140
+ function writeBytesAtomically(path, content, mode) {
141
+ const temp = createTemporaryFile(path, content, mode, "atomic");
142
+ try {
143
+ renameSync(temp, path);
144
+ } catch (error) {
145
+ try {
146
+ unlinkSync(temp);
147
+ } catch {
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+ function createTemporaryFile(path, content, mode, suffix) {
153
+ const temp = `${path}.${randomUUID()}.${suffix}`;
154
+ const fd = openSync(temp, "wx", mode);
155
+ try {
156
+ writeFileSync(fd, content);
157
+ fsyncSync(fd);
158
+ closeSync(fd);
159
+ return temp;
160
+ } catch (error) {
161
+ try {
162
+ closeSync(fd);
163
+ } catch {
164
+ }
165
+ try {
166
+ unlinkSync(temp);
167
+ } catch {
168
+ }
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ // src/install.ts
174
+ import {
175
+ chmodSync,
176
+ closeSync as closeSync2,
177
+ existsSync,
178
+ lstatSync as lstatSync2,
179
+ mkdirSync as mkdirSync2,
180
+ openSync as openSync2,
181
+ readFileSync as readFileSync2,
182
+ readlinkSync,
183
+ renameSync as renameSync2,
184
+ rmSync as rmSync2,
185
+ symlinkSync,
186
+ writeFileSync as writeFileSync2
187
+ } from "node:fs";
188
+ import { homedir as homedir2 } from "node:os";
189
+ import { basename, dirname as dirname2, join as join2, relative, resolve as resolve2, sep } from "node:path";
190
+ import { randomUUID as randomUUID2 } from "node:crypto";
191
+ import { spawnSync } from "node:child_process";
192
+ var PACKAGE_NAME = "@longleash/cli";
193
+ var MARKER = ".longleash-managed-install.json";
194
+ var WRAPPER_MARKER = "# Managed by @longleash/cli";
195
+ function installPaths(env = process.env) {
196
+ const home = resolve2(env.LONGLEASH_INSTALL_HOME ?? join2(homedir2(), ".local", "share", "longleash"));
197
+ const bin = resolve2(env.LONGLEASH_BIN_DIR ?? join2(homedir2(), ".local", "bin"));
198
+ assertSafeInstallHome(home, env.LONGLEASH_INSTALL_HOME !== void 0);
199
+ assertSafeInstallPath(bin, env.LONGLEASH_BIN_DIR !== void 0, "binary directory");
200
+ return { home, releases: join2(home, "releases"), current: join2(home, "current"), bin, wrapper: join2(bin, "longleash") };
201
+ }
202
+ function assertVersion(value) {
203
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value)) {
204
+ throw new Error(`Invalid LongLeash version: ${value}`);
205
+ }
206
+ return value;
207
+ }
208
+ function packageLocation(prefix) {
209
+ return join2(prefix, "node_modules", "@longleash", "cli");
210
+ }
211
+ function verifyInstalledPackage(prefix, expectedVersion) {
212
+ const root = packageLocation(prefix);
213
+ const manifest2 = JSON.parse(readFileSync2(join2(root, "package.json"), "utf8"));
214
+ if (manifest2.name !== PACKAGE_NAME || manifest2.version !== expectedVersion) {
215
+ throw new Error(`Installed package identity mismatch: expected ${PACKAGE_NAME}@${expectedVersion}.`);
216
+ }
217
+ const cli = join2(root, "bin", "longleash.mjs");
218
+ const daemon = join2(root, "runtime", "daemon", "bin", "longleashd.mjs");
219
+ const app = join2(root, "runtime", "app", "dist", "index.html");
220
+ for (const path of [cli, daemon, app]) {
221
+ if (!existsSync(path)) throw new Error(`Installed package is incomplete: missing ${path}`);
222
+ }
223
+ return cli;
224
+ }
225
+ function prepareManagedInstall(version, env = process.env) {
226
+ assertVersion(version);
227
+ const paths = installPaths(env);
228
+ const localPackage = env.LONGLEASH_PACKAGE_SPEC;
229
+ if (localPackage !== void 0 && env.LONGLEASH_ALLOW_LOCAL_PACKAGE !== "1") {
230
+ throw new Error("A local package override requires LONGLEASH_ALLOW_LOCAL_PACKAGE=1. It is intended only for isolated release tests.");
231
+ }
232
+ const packageSpec = localPackage ?? `${PACKAGE_NAME}@${version}`;
233
+ if (localPackage !== void 0 && (!localPackage.endsWith(".tgz") || !existsSync(resolve2(localPackage)))) {
234
+ throw new Error("The local package override must point to an existing .tgz tarball.");
235
+ }
236
+ mkdirSync2(paths.releases, { recursive: true, mode: 448 });
237
+ const releaseLock = acquireInstallLock(paths.home);
238
+ const release = join2(paths.releases, version);
239
+ try {
240
+ let installedThisRun = false;
241
+ const releaseExists = pathExists2(release);
242
+ if (releaseExists && lstatSync2(release).isSymbolicLink()) {
243
+ throw new Error(`Refusing symlinked LongLeash release: ${release}`);
244
+ }
245
+ let needsInstall = !releaseExists;
246
+ if (!needsInstall) {
247
+ try {
248
+ verifyInstalledPackage(release, version);
249
+ } catch (error) {
250
+ if (isCurrentRelease(paths.current, release)) {
251
+ throw new Error(`The active LongLeash release is incomplete and was preserved for diagnosis: ${String(error)}`);
252
+ }
253
+ safeRemoveRelease(release, paths.releases);
254
+ needsInstall = true;
255
+ }
256
+ }
257
+ if (needsInstall) {
258
+ const stage = join2(paths.releases, `.${version}-${process.pid}-${randomUUID2()}.stage`);
259
+ mkdirSync2(stage, { recursive: false, mode: 448 });
260
+ const installArgs = [
261
+ "install",
262
+ "--prefix",
263
+ stage,
264
+ "--omit=dev",
265
+ "--no-audit",
266
+ "--no-fund",
267
+ "--package-lock=false",
268
+ "--registry=https://registry.npmjs.org/",
269
+ packageSpec
270
+ ];
271
+ const installed = spawnSync(
272
+ "npm",
273
+ installArgs,
274
+ { stdio: "inherit", env }
275
+ );
276
+ if (installed.error || installed.status !== 0) {
277
+ safeRemoveStage(stage, paths.releases);
278
+ throw new Error(`npm could not stage ${PACKAGE_NAME}@${version}. The active version was not changed.`);
279
+ }
280
+ try {
281
+ verifyInstalledPackage(stage, version);
282
+ renameSync2(stage, release);
283
+ installedThisRun = true;
284
+ } catch (error) {
285
+ safeRemoveStage(stage, paths.releases);
286
+ throw error;
287
+ }
288
+ }
289
+ const cli = verifyInstalledPackage(release, version);
290
+ let activated = false;
291
+ let lockHeld = true;
292
+ const unlock = () => {
293
+ if (!lockHeld) return;
294
+ lockHeld = false;
295
+ releaseLock();
296
+ };
297
+ return {
298
+ cli,
299
+ activate() {
300
+ const marker = join2(paths.home, MARKER);
301
+ const hadWrapper = pathExists2(paths.wrapper);
302
+ const hadMarker = pathExists2(marker);
303
+ let previousWrapper;
304
+ let previousMarker;
305
+ let wrapperWritten = false;
306
+ let markerWritten = false;
307
+ const tempLink = join2(paths.home, `.current-${process.pid}-${randomUUID2()}`);
308
+ try {
309
+ mkdirSync2(paths.bin, { recursive: true, mode: 448 });
310
+ if (hadWrapper) {
311
+ if (lstatSync2(paths.wrapper).isSymbolicLink()) throw new Error(`Refusing symlinked executable: ${paths.wrapper}`);
312
+ previousWrapper = readFileSync2(paths.wrapper, "utf8");
313
+ if (!previousWrapper.includes(WRAPPER_MARKER)) {
314
+ throw new Error(`Refusing to replace an unmanaged executable: ${paths.wrapper}`);
315
+ }
316
+ }
317
+ if (hadMarker) {
318
+ if (lstatSync2(marker).isSymbolicLink()) throw new Error(`Refusing symlinked install marker: ${marker}`);
319
+ previousMarker = readFileSync2(marker, "utf8");
320
+ }
321
+ const activeCli = join2(paths.current, "node_modules", "@longleash", "cli", "bin", "longleash.mjs");
322
+ writeExecutableAtomically(paths.wrapper, wrapper(activeCli));
323
+ wrapperWritten = true;
324
+ writeFileAtomically(marker, `${JSON.stringify({ schema: 1, package: PACKAGE_NAME }, null, 2)}
325
+ `, 384);
326
+ markerWritten = true;
327
+ symlinkSync(release, tempLink, "dir");
328
+ if (pathExists2(paths.current) && !lstatSync2(paths.current).isSymbolicLink()) {
329
+ throw new Error(`Refusing to replace non-symlink path: ${paths.current}`);
330
+ }
331
+ renameSync2(tempLink, paths.current);
332
+ activated = true;
333
+ } catch (error) {
334
+ try {
335
+ rmSync2(tempLink, { force: true });
336
+ } catch {
337
+ }
338
+ const restorationFailures = [];
339
+ if (wrapperWritten) {
340
+ try {
341
+ restoreManagedFile(paths.wrapper, hadWrapper ? previousWrapper : void 0, 448);
342
+ } catch (restoreError) {
343
+ restorationFailures.push(`executable: ${String(restoreError)}`);
344
+ }
345
+ }
346
+ if (markerWritten) {
347
+ try {
348
+ restoreManagedFile(marker, hadMarker ? previousMarker : void 0, 384);
349
+ } catch (restoreError) {
350
+ restorationFailures.push(`marker: ${String(restoreError)}`);
351
+ }
352
+ }
353
+ if (restorationFailures.length > 0) {
354
+ throw new Error(
355
+ `Activation failed (${String(error)}) and automatic restoration was incomplete (${restorationFailures.join("; ")}). Inspect ${paths.home} before retrying.`
356
+ );
357
+ }
358
+ throw error;
359
+ } finally {
360
+ unlock();
361
+ }
362
+ },
363
+ rollback() {
364
+ try {
365
+ if (installedThisRun && !activated && !isCurrentRelease(paths.current, release)) {
366
+ safeRemoveRelease(release, paths.releases);
367
+ }
368
+ } finally {
369
+ unlock();
370
+ }
371
+ }
372
+ };
373
+ } catch (error) {
374
+ releaseLock();
375
+ throw error;
376
+ }
377
+ }
378
+ function uninstallManagedRuntime(env = process.env) {
379
+ const paths = installPaths(env);
380
+ const marker = join2(paths.home, MARKER);
381
+ if (!pathExists2(marker)) return { removed: false, configPreserved: resolve2(env.LONGLEASH_DATA ?? join2(homedir2(), ".longleash")) };
382
+ if (lstatSync2(marker).isSymbolicLink()) throw new Error(`Refusing symlinked install marker: ${marker}`);
383
+ const parsed = JSON.parse(readFileSync2(marker, "utf8"));
384
+ if (parsed.package !== PACKAGE_NAME) throw new Error(`Refusing to remove unrecognized install directory: ${paths.home}`);
385
+ if (pathExists2(paths.wrapper)) {
386
+ if (lstatSync2(paths.wrapper).isSymbolicLink()) throw new Error(`Refusing symlinked executable: ${paths.wrapper}`);
387
+ const wrapperText = readFileSync2(paths.wrapper, "utf8");
388
+ if (!wrapperText.includes(WRAPPER_MARKER)) throw new Error(`Refusing to remove an unmanaged executable: ${paths.wrapper}`);
389
+ rmSync2(paths.wrapper);
390
+ }
391
+ rmSync2(paths.home, { recursive: true });
392
+ return { removed: true, configPreserved: resolve2(env.LONGLEASH_DATA ?? join2(homedir2(), ".longleash")) };
393
+ }
394
+ function wrapper(cli) {
395
+ return `#!/bin/sh
396
+ ${WRAPPER_MARKER}
397
+ exec node ${shellQuote(cli)} "$@"
398
+ `;
399
+ }
400
+ function shellQuote(value) {
401
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
402
+ }
403
+ function writeExecutableAtomically(path, content) {
404
+ const temp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
405
+ try {
406
+ writeFileSync2(temp, content, { encoding: "utf8", mode: 448, flag: "wx" });
407
+ chmodSync(temp, 448);
408
+ renameSync2(temp, path);
409
+ } catch (error) {
410
+ try {
411
+ rmSync2(temp, { force: true });
412
+ } catch {
413
+ }
414
+ throw error;
415
+ }
416
+ }
417
+ function writeFileAtomically(path, content, mode) {
418
+ const temp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
419
+ try {
420
+ writeFileSync2(temp, content, { encoding: "utf8", mode, flag: "wx" });
421
+ renameSync2(temp, path);
422
+ } catch (error) {
423
+ try {
424
+ rmSync2(temp, { force: true });
425
+ } catch {
426
+ }
427
+ throw error;
428
+ }
429
+ }
430
+ function restoreManagedFile(path, content, mode) {
431
+ if (content === void 0) rmSync2(path, { force: true });
432
+ else writeFileAtomically(path, content, mode);
433
+ }
434
+ function pathExists2(path) {
435
+ try {
436
+ lstatSync2(path);
437
+ return true;
438
+ } catch (error) {
439
+ if (error.code === "ENOENT") return false;
440
+ throw error;
441
+ }
442
+ }
443
+ function assertSafeInstallHome(path, explicitlyConfigured) {
444
+ assertSafeInstallPath(path, explicitlyConfigured, "install directory");
445
+ }
446
+ function assertSafeInstallPath(path, explicitlyConfigured, label) {
447
+ const home = resolve2(homedir2());
448
+ const resolved = resolve2(path);
449
+ if (resolved === "/" || resolved === home || resolved === dirname2(home)) {
450
+ throw new Error(`Unsafe LongLeash ${label}: ${resolved}`);
451
+ }
452
+ if (relative(home, resolved).startsWith(`..${sep}`) && !explicitlyConfigured) {
453
+ throw new Error(`Default ${label} escaped the user home: ${resolved}`);
454
+ }
455
+ }
456
+ function safeRemoveStage(path, releases) {
457
+ const rel = relative(releases, path);
458
+ if (!rel.startsWith(".") || rel.includes(sep)) throw new Error(`Refusing unsafe stage cleanup: ${path}`);
459
+ rmSync2(path, { recursive: true, force: true });
460
+ }
461
+ function safeRemoveRelease(path, releases) {
462
+ const rel = relative(releases, path);
463
+ if (rel === "" || rel.startsWith("..") || basename(path) !== rel) throw new Error(`Refusing unsafe release cleanup: ${path}`);
464
+ rmSync2(path, { recursive: true, force: true });
465
+ }
466
+ function isCurrentRelease(current, release) {
467
+ try {
468
+ return resolve2(dirname2(current), readlinkSync(current)) === release;
469
+ } catch {
470
+ return false;
471
+ }
472
+ }
473
+ function acquireInstallLock(home) {
474
+ const path = join2(home, ".install.lock");
475
+ const token = randomUUID2();
476
+ const create = () => {
477
+ const fd = openSync2(path, "wx", 384);
478
+ try {
479
+ writeFileSync2(fd, `${JSON.stringify({ package: PACKAGE_NAME, pid: process.pid, token })}
480
+ `);
481
+ } finally {
482
+ closeSync2(fd);
483
+ }
484
+ };
485
+ try {
486
+ create();
487
+ } catch (error) {
488
+ if (error.code !== "EEXIST") throw error;
489
+ if (lstatSync2(path).isSymbolicLink()) throw new Error(`Refusing symlinked install lock: ${path}`);
490
+ let owner = {};
491
+ try {
492
+ owner = JSON.parse(readFileSync2(path, "utf8"));
493
+ } catch {
494
+ }
495
+ if (owner.package !== PACKAGE_NAME || !Number.isSafeInteger(owner.pid)) {
496
+ throw new Error(`Unrecognized install lock requires manual inspection: ${path}`);
497
+ }
498
+ if (processAlive(owner.pid)) throw new Error(`Another LongLeash setup is already running as process ${owner.pid}.`);
499
+ rmSync2(path);
500
+ create();
501
+ }
502
+ return () => {
503
+ try {
504
+ const owner = JSON.parse(readFileSync2(path, "utf8"));
505
+ if (owner.token === token) rmSync2(path);
506
+ } catch {
507
+ }
508
+ };
509
+ }
510
+ function processAlive(pid) {
511
+ try {
512
+ process.kill(pid, 0);
513
+ return true;
514
+ } catch (error) {
515
+ return error.code === "EPERM";
516
+ }
517
+ }
518
+
519
+ // src/cli.ts
520
+ var here = dirname3(fileURLToPath(import.meta.url));
521
+ var packageRoot = resolve3(here, "..");
522
+ var manifest = JSON.parse(readFileSync3(join3(packageRoot, "package.json"), "utf8"));
523
+ var VERSION = manifest.version;
524
+ var runtime = join3(packageRoot, "runtime", "daemon");
525
+ var daemonEntry = join3(runtime, "bin", "longleashd.mjs");
526
+ var hooks = join3(runtime, "hooks");
527
+ var devicesEntry = join3(runtime, "bin", "longleash-devices.mjs");
528
+ async function main() {
529
+ const args = process.argv.slice(2);
530
+ const command = args[0];
531
+ if (command === "--version" || command === "-v" || command === "version") {
532
+ console.log(VERSION);
533
+ return 0;
534
+ }
535
+ if (command === "--help" || command === "-h" || command === "help") {
536
+ help();
537
+ return 0;
538
+ }
539
+ if (command === "where") {
540
+ console.log(packageRoot);
541
+ return 0;
542
+ }
543
+ if (command === "setup") return setup(parseSetup(args.slice(1)));
544
+ if (command === "doctor") return doctor(args.includes("--json"));
545
+ if (command === "hooks") return runHooks(args.slice(1));
546
+ if (command === "devices") return runNode(devicesEntry, []);
547
+ if (command === "revoke") return runNode(devicesEntry, ["revoke", ...args.slice(1)]);
548
+ if (command === "update") return update(args[1] ?? "latest");
549
+ if (command === "uninstall") return uninstall();
550
+ if (command === "run") return runDaemon(args.slice(1));
551
+ if (command?.startsWith("-")) {
552
+ console.error(`Unknown option: ${command}
553
+ `);
554
+ help();
555
+ return 2;
556
+ }
557
+ return runDaemon(args);
558
+ }
559
+ function help() {
560
+ console.log(`LongLeash ${VERSION}
561
+
562
+ Usage:
563
+ longleash setup configure a stable user-local installation
564
+ longleash run [folders...] run in the foreground (terminal stays open)
565
+ longleash [folders...] shorthand for run
566
+ longleash doctor [--json] report package, config, and daemon health
567
+ longleash hooks [--remove] install or remove supported provider hooks
568
+ longleash devices list paired phones
569
+ longleash revoke <id|--all> revoke paired phone access
570
+ longleash update [version] atomically install latest or an exact version
571
+ longleash uninstall remove runtime and hooks; preserve user data
572
+ longleash where print the active npm package directory
573
+
574
+ Setup never starts the daemon. Background service commands arrive in Workstream C.`);
575
+ }
576
+ function parseSetup(args) {
577
+ const options = { roots: [], relay: "", yes: false, reuse: false, configureOnly: false, skipHooks: false };
578
+ for (let i = 0; i < args.length; i += 1) {
579
+ const arg = args[i];
580
+ if (arg === "--root") {
581
+ const value = args[++i];
582
+ if (!value) throw new Error("--root requires a directory.");
583
+ options.roots.push(value);
584
+ } else if (arg === "--relay") {
585
+ const value = args[++i];
586
+ if (!value) throw new Error("--relay requires hosted, off, or a wss:// URL.");
587
+ options.relay = value;
588
+ } else if (arg === "--yes") options.yes = true;
589
+ else if (arg === "--reuse-config") options.reuse = true;
590
+ else if (arg === "--configure-only") options.configureOnly = true;
591
+ else if (arg === "--skip-hooks") options.skipHooks = true;
592
+ else throw new Error(`Unknown setup option: ${arg}`);
593
+ }
594
+ return options;
595
+ }
596
+ async function setup(options) {
597
+ if (options.configureOnly && process.env.LONGLEASH_INTERNAL_CONFIGURE !== VERSION) {
598
+ throw new Error("--configure-only is an internal transaction step and cannot be invoked directly.");
599
+ }
600
+ let existing;
601
+ try {
602
+ existing = loadConfig();
603
+ } catch (error) {
604
+ throw new Error(`${message(error)} Fix or move ${configPath()} before setup.`);
605
+ }
606
+ if (options.reuse) {
607
+ options.roots = configuredRoots(existing);
608
+ options.relay = typeof existing.relayUrl === "string" ? existing.relayUrl : "off";
609
+ options.yes = true;
610
+ }
611
+ if (options.roots.length === 0 && options.yes) {
612
+ throw new Error("Non-interactive setup requires at least one explicit --root, or --reuse-config.");
613
+ }
614
+ if (!options.yes) {
615
+ if (!input.isTTY || !output.isTTY) {
616
+ throw new Error("Interactive setup needs a terminal. Use --yes with explicit --root and --relay values.");
617
+ }
618
+ const prompt = createInterface({ input, output });
619
+ try {
620
+ console.log("\nLongLeash setup\n");
621
+ console.log("Agents remain on this laptop. Choose only folders where they may work.");
622
+ const rootAnswer = await prompt.question(`Allowed project folder [${process.cwd()}]: `);
623
+ options.roots = [rootAnswer.trim() || process.cwd()];
624
+ console.log("\nConnectivity: hosted works away from home; LAN-only has no internet relay.");
625
+ const relayAnswer = await prompt.question("Relay [hosted / lan / wss://your-relay/ws] (hosted): ");
626
+ options.relay = relayAnswer.trim() || "hosted";
627
+ const roots2 = normalizeRoots(options.roots);
628
+ const relay2 = normalizeRelay(options.relay);
629
+ console.log("\nReview before anything changes:");
630
+ for (const root of roots2) console.log(` allowed root ${root}`);
631
+ console.log(` connectivity ${relay2 ?? "LAN-only (no relay)"}`);
632
+ console.log(" service not installed or started in this workstream");
633
+ const accepted = await prompt.question("\nApply this setup? [y/N] ");
634
+ if (!/^y(?:es)?$/i.test(accepted.trim())) {
635
+ console.log("Nothing changed.");
636
+ return 1;
637
+ }
638
+ } finally {
639
+ prompt.close();
640
+ }
641
+ }
642
+ const roots = normalizeRoots(options.roots);
643
+ const relay = normalizeRelay(options.relay || "hosted");
644
+ if (!options.configureOnly) {
645
+ const configSnapshot = captureConfigSnapshot();
646
+ const prepared = prepareManagedInstall(VERSION);
647
+ const forwarded = [
648
+ "setup",
649
+ "--configure-only",
650
+ "--yes",
651
+ ...roots.flatMap((root) => ["--root", root]),
652
+ "--relay",
653
+ relay ?? "off",
654
+ "--skip-hooks"
655
+ ];
656
+ const configured = spawnSync2(process.execPath, [prepared.cli, ...forwarded], {
657
+ stdio: "inherit",
658
+ env: { ...process.env, LONGLEASH_INTERNAL_CONFIGURE: VERSION }
659
+ });
660
+ if (configured.error || configured.status !== 0) {
661
+ prepared.rollback();
662
+ restoreConfigSnapshot(configSnapshot);
663
+ throw new Error("The staged package could not be configured. The active version was not changed.");
664
+ }
665
+ try {
666
+ prepared.activate();
667
+ } catch (error) {
668
+ prepared.rollback();
669
+ restoreConfigSnapshot(configSnapshot);
670
+ throw error;
671
+ }
672
+ console.log(`
673
+ Activated @longleash/cli ${VERSION}.`);
674
+ if (!options.skipHooks) {
675
+ const hooked = spawnSync2(process.execPath, [prepared.cli, "hooks"], { stdio: "inherit", env: process.env });
676
+ if (hooked.error || hooked.status !== 0) {
677
+ console.error("Provider hook setup needs attention. The runtime is installed; run `longleash hooks` after fixing the message above.");
678
+ }
679
+ }
680
+ const paths = installPaths();
681
+ if (pathDirectories().includes(paths.bin)) {
682
+ console.log("Open a new terminal if `longleash` is not yet visible in this shell.");
683
+ } else {
684
+ console.log(`
685
+ The executable is installed at ${paths.wrapper}, but ${paths.bin} is not on PATH.`);
686
+ console.log("Add this line to your shell profile, then open a new terminal:");
687
+ console.log(` export PATH=${quoteForShell(paths.bin)}:"$PATH"`);
688
+ console.log(`You can use ${paths.wrapper} immediately.`);
689
+ }
690
+ return 0;
691
+ }
692
+ const next = { ...existing, cliSchemaVersion: 1, allowedRoots: roots };
693
+ if (relay === null) delete next.relayUrl;
694
+ else next.relayUrl = relay;
695
+ saveConfig(next);
696
+ console.log(`Saved configuration to ${configPath()}`);
697
+ console.log("LongLeash was not started. Run `longleash run` when you are ready.");
698
+ return 0;
699
+ }
700
+ async function runDaemon(explicitRoots) {
701
+ if (!existsSync2(daemonEntry)) throw new Error("The npm package is incomplete: daemon runtime is missing.");
702
+ const config = loadConfig();
703
+ const roots = normalizeRoots(explicitRoots.length > 0 ? explicitRoots : configuredRoots(config));
704
+ const child = spawn(process.execPath, [daemonEntry, ...roots], { stdio: "inherit", env: process.env });
705
+ return await new Promise((resolveExit, reject) => {
706
+ child.once("error", reject);
707
+ child.once("exit", (code, signal) => resolveExit(code ?? (signal ? 1 : 0)));
708
+ });
709
+ }
710
+ async function doctor(json) {
711
+ let config = {};
712
+ let configError = null;
713
+ try {
714
+ config = loadConfig();
715
+ } catch (error) {
716
+ configError = message(error);
717
+ }
718
+ const endpointPath = join3(process.env.LONGLEASH_DATA ?? join3(homedir3(), ".longleash"), "hook-endpoint.json");
719
+ let daemon = { reachable: false };
720
+ try {
721
+ const endpoint = JSON.parse(readFileSync3(endpointPath, "utf8"));
722
+ if (endpoint.url && endpoint.secret) {
723
+ const response = await fetch(endpoint.url.replace(/\/hook$/, "/health"), {
724
+ headers: { "x-longleash-hook": endpoint.secret },
725
+ signal: AbortSignal.timeout(1500)
726
+ });
727
+ if (response.ok) {
728
+ const health = await response.json();
729
+ daemon = { reachable: true, ...health.build ? { build: health.build } : {} };
730
+ }
731
+ }
732
+ } catch {
733
+ }
734
+ const report = {
735
+ package: { name: "@longleash/cli", version: VERSION, root: packageRoot },
736
+ runtime: { node: process.version, platform: process.platform, architecture: process.arch },
737
+ config: {
738
+ path: configPath(),
739
+ valid: configError === null,
740
+ error: configError,
741
+ allowedRoots: configuredRoots(config),
742
+ relay: typeof config.relayUrl === "string" ? config.relayUrl : "off"
743
+ },
744
+ daemon
745
+ };
746
+ if (json) console.log(JSON.stringify(report, null, 2));
747
+ else {
748
+ console.log(`
749
+ LongLeash ${VERSION}`);
750
+ console.log(` package ${packageRoot}`);
751
+ console.log(` node ${process.version} (${process.platform} ${process.arch})`);
752
+ console.log(` config ${configError ?? (configuredRoots(config).length > 0 ? "valid" : "not configured")}`);
753
+ console.log(` relay ${report.config.relay}`);
754
+ console.log(` daemon ${daemon.reachable ? `reachable \xB7 build ${daemon.build ?? "unknown"}` : "not running"}`);
755
+ console.log("");
756
+ }
757
+ return configError === null ? 0 : 1;
758
+ }
759
+ async function runHooks(args) {
760
+ const remove = args.includes("--remove");
761
+ let failed = false;
762
+ if (commandAvailable("claude") || remove) failed = await runNode(join3(hooks, "install-hooks.mjs"), remove ? ["--remove"] : []) !== 0 || failed;
763
+ if (commandAvailable("codex") || remove) failed = await runNode(join3(hooks, "install-codex-hooks.mjs"), remove ? ["--remove"] : []) !== 0 || failed;
764
+ return failed ? 1 : 0;
765
+ }
766
+ async function update(target) {
767
+ const spec = target === "latest" ? "@longleash/cli@latest" : `@longleash/cli@${assertVersion(target)}`;
768
+ const child = spawn("npm", [
769
+ "exec",
770
+ "--yes",
771
+ "--registry=https://registry.npmjs.org/",
772
+ `--package=${spec}`,
773
+ "--",
774
+ "longleash",
775
+ "setup",
776
+ "--reuse-config",
777
+ "--yes"
778
+ ], {
779
+ stdio: "inherit",
780
+ env: process.env
781
+ });
782
+ return await new Promise((resolveExit, reject) => {
783
+ child.once("error", reject);
784
+ child.once("exit", (code) => resolveExit(code ?? 1));
785
+ });
786
+ }
787
+ async function uninstall() {
788
+ const hooksRemoved = await runHooks(["--remove"]);
789
+ if (hooksRemoved !== 0) throw new Error("Provider hooks could not be removed safely. The managed runtime was preserved.");
790
+ const result = uninstallManagedRuntime();
791
+ console.log(result.removed ? "Removed the managed LongLeash runtime and executable." : "No managed LongLeash runtime was installed.");
792
+ console.log(`Preserved settings, paired devices, and audit data in ${result.configPreserved}.`);
793
+ return 0;
794
+ }
795
+ function commandAvailable(command) {
796
+ return spawnSync2(command, ["--version"], { stdio: "ignore", timeout: 3e3 }).status === 0;
797
+ }
798
+ function pathDirectories() {
799
+ return (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((path) => resolve3(path));
800
+ }
801
+ function quoteForShell(value) {
802
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
803
+ }
804
+ async function runNode(entry, args) {
805
+ const child = spawn(process.execPath, [entry, ...args], { stdio: "inherit", env: process.env });
806
+ return await new Promise((resolveExit, reject) => {
807
+ child.once("error", reject);
808
+ child.once("exit", (code) => resolveExit(code ?? 1));
809
+ });
810
+ }
811
+ function message(error) {
812
+ return error instanceof Error ? error.message : String(error);
813
+ }
814
+ try {
815
+ process.exitCode = await main();
816
+ } catch (error) {
817
+ console.error(`
818
+ LongLeash: ${message(error)}
819
+ `);
820
+ process.exitCode = 1;
821
+ }