lunel-cli 0.1.76 → 0.1.78
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/dist/index.js +282 -6
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -24,7 +24,25 @@ const PTY_RELEASE_INFO_URL = process.env.LUNEL_PTY_INFO_URL ||
|
|
|
24
24
|
"https://raw.githubusercontent.com/ssbharambe-m/pty-releases/refs/heads/main/info.json";
|
|
25
25
|
const VERBOSE_AI_LOGS = process.env.LUNEL_DEBUG_AI === "1";
|
|
26
26
|
// Root directory - sandbox all file operations to this
|
|
27
|
-
const ROOT_DIR =
|
|
27
|
+
const ROOT_DIR = (() => {
|
|
28
|
+
try {
|
|
29
|
+
return fssync.realpathSync(process.cwd());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return process.cwd();
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
const CLI_CONFIG_PATH = (() => {
|
|
36
|
+
if (process.platform === "darwin") {
|
|
37
|
+
return path.join(os.homedir(), "Library", "Application Support", "lunel", "config.json");
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === "win32") {
|
|
40
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
41
|
+
return path.join(appData, "lunel", "config.json");
|
|
42
|
+
}
|
|
43
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
44
|
+
return path.join(xdgConfig, "lunel", "config.json");
|
|
45
|
+
})();
|
|
28
46
|
// Terminal sessions (managed by Rust PTY binary)
|
|
29
47
|
const terminals = new Set();
|
|
30
48
|
// PTY binary process
|
|
@@ -313,6 +331,104 @@ function assertSafePath(requestedPath) {
|
|
|
313
331
|
}
|
|
314
332
|
return safePath;
|
|
315
333
|
}
|
|
334
|
+
function generatePersistentSecret(length) {
|
|
335
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
336
|
+
const bytes = crypto.randomBytes(length);
|
|
337
|
+
let out = "";
|
|
338
|
+
for (let i = 0; i < length; i++) {
|
|
339
|
+
out += alphabet[bytes[i] % alphabet.length];
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
function normalizePairingRoot(input) {
|
|
344
|
+
try {
|
|
345
|
+
return fssync.realpathSync(input);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return path.resolve(input);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function readCliConfig() {
|
|
352
|
+
try {
|
|
353
|
+
const raw = await fs.readFile(CLI_CONFIG_PATH, "utf-8");
|
|
354
|
+
const parsed = JSON.parse(raw);
|
|
355
|
+
const pairings = Array.isArray(parsed.pairings)
|
|
356
|
+
? parsed.pairings
|
|
357
|
+
.filter((entry) => {
|
|
358
|
+
return Boolean(entry &&
|
|
359
|
+
typeof entry.secret === "string" &&
|
|
360
|
+
typeof entry.root === "string" &&
|
|
361
|
+
typeof entry.hostname === "string" &&
|
|
362
|
+
typeof entry.phoneId === "string" &&
|
|
363
|
+
typeof entry.pairedAt === "number" &&
|
|
364
|
+
typeof entry.lastUsedAt === "number");
|
|
365
|
+
})
|
|
366
|
+
.map((entry) => ({
|
|
367
|
+
...entry,
|
|
368
|
+
root: normalizePairingRoot(entry.root),
|
|
369
|
+
}))
|
|
370
|
+
: [];
|
|
371
|
+
return {
|
|
372
|
+
version: 1,
|
|
373
|
+
deviceId: typeof parsed.deviceId === "string" && parsed.deviceId ? parsed.deviceId : generatePersistentSecret(32),
|
|
374
|
+
pairings,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return {
|
|
379
|
+
version: 1,
|
|
380
|
+
deviceId: generatePersistentSecret(32),
|
|
381
|
+
pairings: [],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function writeCliConfig(config) {
|
|
386
|
+
await fs.mkdir(path.dirname(CLI_CONFIG_PATH), { recursive: true });
|
|
387
|
+
await fs.writeFile(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
388
|
+
}
|
|
389
|
+
let cliConfigPromise = null;
|
|
390
|
+
async function getCliConfig() {
|
|
391
|
+
if (!cliConfigPromise) {
|
|
392
|
+
cliConfigPromise = readCliConfig();
|
|
393
|
+
}
|
|
394
|
+
return await cliConfigPromise;
|
|
395
|
+
}
|
|
396
|
+
async function persistCliConfig(mutator) {
|
|
397
|
+
const current = await getCliConfig();
|
|
398
|
+
const next = mutator({
|
|
399
|
+
version: 1,
|
|
400
|
+
deviceId: current.deviceId,
|
|
401
|
+
pairings: [...current.pairings],
|
|
402
|
+
});
|
|
403
|
+
cliConfigPromise = Promise.resolve(next);
|
|
404
|
+
await writeCliConfig(next);
|
|
405
|
+
return next;
|
|
406
|
+
}
|
|
407
|
+
async function getSavedPairingForRoot(root) {
|
|
408
|
+
const normalizedRoot = normalizePairingRoot(root);
|
|
409
|
+
const config = await getCliConfig();
|
|
410
|
+
const matches = config.pairings
|
|
411
|
+
.filter((entry) => entry.root === normalizedRoot)
|
|
412
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
413
|
+
return matches[0] || null;
|
|
414
|
+
}
|
|
415
|
+
async function savePairing(pairing) {
|
|
416
|
+
await persistCliConfig((config) => {
|
|
417
|
+
const deduped = config.pairings.filter((entry) => entry.secret !== pairing.secret);
|
|
418
|
+
deduped.push(pairing);
|
|
419
|
+
deduped.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
420
|
+
return {
|
|
421
|
+
...config,
|
|
422
|
+
pairings: deduped.slice(0, 50),
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
async function removePairing(secret) {
|
|
427
|
+
await persistCliConfig((config) => ({
|
|
428
|
+
...config,
|
|
429
|
+
pairings: config.pairings.filter((entry) => entry.secret !== secret),
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
316
432
|
// ============================================================================
|
|
317
433
|
// File System Handlers
|
|
318
434
|
// ============================================================================
|
|
@@ -2206,6 +2322,14 @@ async function processMessage(message) {
|
|
|
2206
2322
|
case "ping":
|
|
2207
2323
|
result = handleSystemPing();
|
|
2208
2324
|
break;
|
|
2325
|
+
case "pairDevice": {
|
|
2326
|
+
const phoneId = typeof payload.phoneId === "string" ? payload.phoneId.trim() : "";
|
|
2327
|
+
if (!phoneId) {
|
|
2328
|
+
throw Object.assign(new Error("phoneId is required"), { code: "EINVAL" });
|
|
2329
|
+
}
|
|
2330
|
+
result = { ...(await registerPersistentPairing(phoneId)) };
|
|
2331
|
+
break;
|
|
2332
|
+
}
|
|
2209
2333
|
default:
|
|
2210
2334
|
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2211
2335
|
}
|
|
@@ -2582,7 +2706,7 @@ async function resolveSessionByResumeToken(resumeToken) {
|
|
|
2582
2706
|
}
|
|
2583
2707
|
return {
|
|
2584
2708
|
sessionId: snapshot.sessionId || "",
|
|
2585
|
-
code: snapshot.code,
|
|
2709
|
+
code: snapshot.code || "",
|
|
2586
2710
|
password: snapshot.resumeToken,
|
|
2587
2711
|
primary: snapshot.primary,
|
|
2588
2712
|
backup: snapshot.backup ?? null,
|
|
@@ -2590,6 +2714,85 @@ async function resolveSessionByResumeToken(resumeToken) {
|
|
|
2590
2714
|
expiresAt: snapshot.expiresAt,
|
|
2591
2715
|
};
|
|
2592
2716
|
}
|
|
2717
|
+
async function registerPersistentPairing(phoneId) {
|
|
2718
|
+
if (!currentSessionPassword) {
|
|
2719
|
+
throw new Error("No active session secret available for pairing");
|
|
2720
|
+
}
|
|
2721
|
+
const config = await getCliConfig();
|
|
2722
|
+
const response = await fetch(`${MANAGER_URL}/v1/pairings/register`, {
|
|
2723
|
+
method: "POST",
|
|
2724
|
+
headers: { "Content-Type": "application/json" },
|
|
2725
|
+
body: JSON.stringify({
|
|
2726
|
+
activeSecret: currentSessionPassword,
|
|
2727
|
+
phoneId,
|
|
2728
|
+
pcId: config.deviceId,
|
|
2729
|
+
root: ROOT_DIR,
|
|
2730
|
+
hostname: os.hostname(),
|
|
2731
|
+
}),
|
|
2732
|
+
});
|
|
2733
|
+
if (!response.ok) {
|
|
2734
|
+
throw new Error(`Failed to register persistent pairing (${response.status})`);
|
|
2735
|
+
}
|
|
2736
|
+
const payload = await response.json();
|
|
2737
|
+
if (typeof payload.secret !== "string" ||
|
|
2738
|
+
typeof payload.hostname !== "string" ||
|
|
2739
|
+
typeof payload.root !== "string" ||
|
|
2740
|
+
typeof payload.phoneId !== "string" ||
|
|
2741
|
+
typeof payload.pairedAt !== "number" ||
|
|
2742
|
+
typeof payload.lastUsedAt !== "number") {
|
|
2743
|
+
throw new Error("Manager returned invalid pairing response");
|
|
2744
|
+
}
|
|
2745
|
+
const normalized = {
|
|
2746
|
+
secret: payload.secret,
|
|
2747
|
+
hostname: payload.hostname,
|
|
2748
|
+
root: normalizePairingRoot(payload.root),
|
|
2749
|
+
phoneId: payload.phoneId,
|
|
2750
|
+
pairedAt: payload.pairedAt,
|
|
2751
|
+
lastUsedAt: payload.lastUsedAt,
|
|
2752
|
+
};
|
|
2753
|
+
await savePairing({
|
|
2754
|
+
secret: normalized.secret,
|
|
2755
|
+
root: normalized.root,
|
|
2756
|
+
hostname: normalized.hostname,
|
|
2757
|
+
phoneId: normalized.phoneId,
|
|
2758
|
+
pairedAt: normalized.pairedAt,
|
|
2759
|
+
lastUsedAt: normalized.lastUsedAt,
|
|
2760
|
+
});
|
|
2761
|
+
currentSessionPassword = normalized.secret;
|
|
2762
|
+
return normalized;
|
|
2763
|
+
}
|
|
2764
|
+
async function lookupPersistentPairings() {
|
|
2765
|
+
const config = await getCliConfig();
|
|
2766
|
+
const response = await fetch(`${MANAGER_URL}/v1/pairings/lookup`, {
|
|
2767
|
+
method: "POST",
|
|
2768
|
+
headers: { "Content-Type": "application/json" },
|
|
2769
|
+
body: JSON.stringify({
|
|
2770
|
+
pcId: config.deviceId,
|
|
2771
|
+
root: ROOT_DIR,
|
|
2772
|
+
}),
|
|
2773
|
+
});
|
|
2774
|
+
if (!response.ok) {
|
|
2775
|
+
throw new Error(`Failed to look up persistent pairings (${response.status})`);
|
|
2776
|
+
}
|
|
2777
|
+
const payload = await response.json();
|
|
2778
|
+
const pairings = Array.isArray(payload.pairings) ? payload.pairings : [];
|
|
2779
|
+
return pairings
|
|
2780
|
+
.filter((entry) => (entry &&
|
|
2781
|
+
typeof entry.secret === "string" &&
|
|
2782
|
+
typeof entry.hostname === "string" &&
|
|
2783
|
+
typeof entry.root === "string" &&
|
|
2784
|
+
typeof entry.phoneId === "string" &&
|
|
2785
|
+
typeof entry.pairedAt === "number" &&
|
|
2786
|
+
typeof entry.lastUsedAt === "number"))
|
|
2787
|
+
.map((entry) => ({
|
|
2788
|
+
secret: entry.secret,
|
|
2789
|
+
root: normalizePairingRoot(entry.root),
|
|
2790
|
+
hostname: entry.hostname,
|
|
2791
|
+
phoneId: entry.phoneId,
|
|
2792
|
+
pairedAt: entry.pairedAt,
|
|
2793
|
+
lastUsedAt: entry.lastUsedAt,
|
|
2794
|
+
}));
|
|
2795
|
+
}
|
|
2593
2796
|
function displayQR(primaryGateway, backupGateway, code) {
|
|
2594
2797
|
console.log("\n");
|
|
2595
2798
|
qrcode.generate(code, { small: true }, (qr) => {
|
|
@@ -2706,9 +2909,13 @@ async function connectWebSocket() {
|
|
|
2706
2909
|
if (message.type === "connected")
|
|
2707
2910
|
return;
|
|
2708
2911
|
if (message.type === "session_password" && message.password) {
|
|
2912
|
+
const nextPassword = message.password;
|
|
2913
|
+
if (currentSessionPassword && currentSessionPassword.length > nextPassword.length) {
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2709
2916
|
if (!currentSessionPassword)
|
|
2710
2917
|
resetReplayBuffer(); // new session
|
|
2711
|
-
currentSessionPassword =
|
|
2918
|
+
currentSessionPassword = nextPassword;
|
|
2712
2919
|
console.log("[session] received reconnect password");
|
|
2713
2920
|
return;
|
|
2714
2921
|
}
|
|
@@ -2907,6 +3114,7 @@ async function main() {
|
|
|
2907
3114
|
console.log("[review] Running in pinned review session mode (code: abcd)");
|
|
2908
3115
|
}
|
|
2909
3116
|
try {
|
|
3117
|
+
await getCliConfig();
|
|
2910
3118
|
console.log("Checking PTY runtime...");
|
|
2911
3119
|
await ensurePtyBinaryReady();
|
|
2912
3120
|
console.log("PTY runtime ready.\n");
|
|
@@ -2936,9 +3144,77 @@ async function main() {
|
|
|
2936
3144
|
console.log(`[cloud] Connected to session ${session.code} via ${session.primary}`);
|
|
2937
3145
|
}
|
|
2938
3146
|
else {
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3147
|
+
let savedPairing = await getSavedPairingForRoot(ROOT_DIR);
|
|
3148
|
+
if (!savedPairing) {
|
|
3149
|
+
try {
|
|
3150
|
+
const remotePairings = await lookupPersistentPairings();
|
|
3151
|
+
for (const pairing of remotePairings) {
|
|
3152
|
+
await savePairing(pairing);
|
|
3153
|
+
}
|
|
3154
|
+
savedPairing = remotePairings.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0] || null;
|
|
3155
|
+
}
|
|
3156
|
+
catch {
|
|
3157
|
+
// fall back to QR
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
if (savedPairing) {
|
|
3161
|
+
try {
|
|
3162
|
+
const resolved = await resolveSessionByResumeToken(savedPairing.secret);
|
|
3163
|
+
if (resolved) {
|
|
3164
|
+
session = resolved;
|
|
3165
|
+
console.log(`[pairing] Waiting on saved connection for ${savedPairing.hostname}`);
|
|
3166
|
+
}
|
|
3167
|
+
else {
|
|
3168
|
+
await removePairing(savedPairing.secret);
|
|
3169
|
+
const remotePairings = await lookupPersistentPairings().catch(() => []);
|
|
3170
|
+
const fallbackPairing = remotePairings.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0] || null;
|
|
3171
|
+
if (fallbackPairing) {
|
|
3172
|
+
await savePairing(fallbackPairing);
|
|
3173
|
+
const fallbackResolved = await resolveSessionByResumeToken(fallbackPairing.secret);
|
|
3174
|
+
if (fallbackResolved) {
|
|
3175
|
+
session = fallbackResolved;
|
|
3176
|
+
console.log(`[pairing] Waiting on saved connection for ${fallbackPairing.hostname}`);
|
|
3177
|
+
}
|
|
3178
|
+
else {
|
|
3179
|
+
await removePairing(fallbackPairing.secret);
|
|
3180
|
+
session = await createSessionFromManager();
|
|
3181
|
+
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
else {
|
|
3185
|
+
session = await createSessionFromManager();
|
|
3186
|
+
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
catch {
|
|
3191
|
+
await removePairing(savedPairing.secret);
|
|
3192
|
+
const remotePairings = await lookupPersistentPairings().catch(() => []);
|
|
3193
|
+
const fallbackPairing = remotePairings.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0] || null;
|
|
3194
|
+
if (fallbackPairing) {
|
|
3195
|
+
await savePairing(fallbackPairing);
|
|
3196
|
+
const fallbackResolved = await resolveSessionByResumeToken(fallbackPairing.secret);
|
|
3197
|
+
if (fallbackResolved) {
|
|
3198
|
+
session = fallbackResolved;
|
|
3199
|
+
console.log(`[pairing] Waiting on saved connection for ${fallbackPairing.hostname}`);
|
|
3200
|
+
}
|
|
3201
|
+
else {
|
|
3202
|
+
await removePairing(fallbackPairing.secret);
|
|
3203
|
+
session = await createSessionFromManager();
|
|
3204
|
+
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
else {
|
|
3208
|
+
session = await createSessionFromManager();
|
|
3209
|
+
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
else {
|
|
3214
|
+
// Public mode: create a new session and show QR code
|
|
3215
|
+
session = await createSessionFromManager();
|
|
3216
|
+
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3217
|
+
}
|
|
2942
3218
|
}
|
|
2943
3219
|
currentPrimaryGateway = normalizeGatewayUrl(session.primary);
|
|
2944
3220
|
currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
|