@zonease/happy 0.6.39 → 0.6.41
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/bin/happy.mjs +10 -0
- package/dist/{api-CIZKyfSn.mjs → api-K2-jr9ar.mjs} +86 -6
- package/dist/{api-CwOCQeLH.cjs → api-XeHlmApX.cjs} +87 -7
- package/dist/{auth-BIleVCw0.cjs → auth-BQvEuSs5.cjs} +2 -2
- package/dist/{auth-Cas9DR3b.cjs → auth-BbvPcnkY.cjs} +4 -4
- package/dist/{auth-0VnlXT9f.mjs → auth-CjLkoM3m.mjs} +4 -4
- package/dist/{auth-BE8dD20T.mjs → auth-DS8fzlin.mjs} +2 -2
- package/dist/{browser-Dll5hxOV.mjs → browser-C05Ak6RL.mjs} +1 -1
- package/dist/{browser-Cc08m7of.cjs → browser-C05SP_Tt.cjs} +1 -1
- package/dist/{claudeLocal-WkgLbl1a.cjs → claudeLocal-CpDhLCcZ.cjs} +1 -1
- package/dist/{claudeLocal-CPT3zcIU.mjs → claudeLocal-DJNRlHIC.mjs} +1 -1
- package/dist/{config-Draa-5XC.cjs → config-B0mns9fF.cjs} +1 -1
- package/dist/{config-rTT4qTU5.mjs → config-DWWtjKQx.mjs} +1 -1
- package/dist/{connect-CJZ73syK.cjs → connect-BxCScVNX.cjs} +4 -4
- package/dist/{connect-BfC1tYP_.mjs → connect-DXQC1CJe.mjs} +4 -4
- package/dist/{controlClient-DZMIsKsT.cjs → controlClient-DgELDZgM.cjs} +10 -1
- package/dist/{controlClient-CM_QcJnB.mjs → controlClient-DgjVRN8D.mjs} +10 -2
- package/dist/deviceKeys-BpIAWz13.cjs +182 -0
- package/dist/deviceKeys-CejCdWpw.mjs +175 -0
- package/dist/{doctor-DZYB6IVc.mjs → doctor-B-LCMVN9.mjs} +3 -3
- package/dist/{doctor-Cya9YSA0.cjs → doctor-DznasgP3.cjs} +3 -3
- package/dist/{downloadImage-MQMrUUxS.cjs → downloadImage-B_ygLAXY.cjs} +5 -5
- package/dist/{downloadImage-DfSFLb-h.mjs → downloadImage-ia5SzlCV.mjs} +3 -3
- package/dist/{enroll-BxlbVftP.cjs → enroll-BvfpRUGJ.cjs} +3 -3
- package/dist/{enroll-C52x8oly.mjs → enroll-HjUqyGnD.mjs} +3 -3
- package/dist/{evidence-Ccd9iYcj.mjs → evidence-BEQd1dCh.mjs} +1 -1
- package/dist/{evidence-CjWBG5rX.cjs → evidence-CVgTjBrq.cjs} +1 -1
- package/dist/{formatImageMessage-DbyG8c2j.cjs → formatImageMessage-CmJTP_hU.cjs} +1 -1
- package/dist/{formatImageMessage-B4lSxlk2.mjs → formatImageMessage-DVs57t3x.mjs} +1 -1
- package/dist/index.cjs +58 -43
- package/dist/index.mjs +58 -43
- package/dist/{install-C_cp8b2-.cjs → install-2Yl_L582.cjs} +2 -2
- package/dist/{install-oRAjVLjP.mjs → install-B3mMGISS.mjs} +1 -1
- package/dist/{install-BokHRgFW.cjs → install-Ck22tYHP.cjs} +1 -1
- package/dist/{install-DAeCn8ae.mjs → install-UTz_vYvB.mjs} +2 -2
- package/dist/lib.cjs +3 -3
- package/dist/lib.d.cts +77 -0
- package/dist/lib.d.mts +77 -0
- package/dist/lib.mjs +3 -3
- package/dist/{loadableToolOutput-Cuygmd4K.mjs → loadableToolOutput-4EvraaNY.mjs} +3 -3
- package/dist/{loadableToolOutput-DDCJjywt.cjs → loadableToolOutput-BZYlaqHn.cjs} +3 -3
- package/dist/{persistence-BFjvtUad.mjs → persistence-CIUFyOwz.mjs} +2 -2
- package/dist/{persistence-JLUn_Nou.cjs → persistence-DrfN7TzY.cjs} +1 -1
- package/dist/push-CCOXCcM_.cjs +80 -0
- package/dist/push-USXj_pv0.mjs +78 -0
- package/dist/{run-BDn2aE3Q.cjs → run-D3CopmEb.cjs} +45 -9
- package/dist/{run-DBZqN6Kn.mjs → run-Dy6KX6m-.mjs} +45 -9
- package/dist/{runClaude-DNHvERX0.mjs → runClaude-CWux2RDq.mjs} +14 -14
- package/dist/{runClaude-uaKxPZPm.cjs → runClaude-aHzvx6Ha.cjs} +15 -15
- package/dist/{runCodex-C-RTM0wA.mjs → runCodex-D0ZxSj3O.mjs} +13 -13
- package/dist/{runCodex-82UrZLme.cjs → runCodex-G-Aq531U.cjs} +13 -13
- package/dist/{runGemini-Bm6-4Xm2.mjs → runGemini-DZC2i6vA.mjs} +15 -15
- package/dist/{runGemini-CXMr4Jwb.cjs → runGemini-DxGgDAIM.cjs} +15 -15
- package/dist/{runOneShot-1B6RMYT7.cjs → runOneShot-BgT-JNMA.cjs} +2 -2
- package/dist/{runOneShot-ioARRHrx.mjs → runOneShot-cxlk7VKA.mjs} +2 -2
- package/dist/{serve-CwibwU6N.mjs → serve-CKyvJNs0.mjs} +6 -6
- package/dist/{serve-BtdAhkIE.cjs → serve-Dr3wPrtK.cjs} +6 -6
- package/dist/{session-D--F87zL.cjs → session-dYzbOo_W.cjs} +5 -5
- package/dist/{session-2sU4Bu1R.mjs → session-vf2ZjFto.mjs} +5 -5
- package/dist/{sessionKeyCache-D2BwO91C.mjs → sessionKeyCache-D2u3JUys.mjs} +1 -1
- package/dist/{sessionKeyCache-4Tp8pJ3N.cjs → sessionKeyCache-dDquDzn0.cjs} +1 -1
- package/dist/{sessionList-BCSw3qYt.mjs → sessionList-C1k2PNg_.mjs} +2 -2
- package/dist/{sessionList-ICJsjNtJ.cjs → sessionList-DmmtC7uR.cjs} +2 -2
- package/dist/{sessionSummary-lHGBWQSb.mjs → sessionSummary-5a1QtFVS.mjs} +3 -3
- package/dist/{sessionSummary-Bkz52BPh.cjs → sessionSummary-DGLoVOxd.cjs} +3 -3
- package/dist/{spawnHappyCLI-D_V-AAoP.cjs → spawnHappyCLI-CarRI4GM.cjs} +1 -1
- package/dist/{spawnHappyCLI-CoORX0YW.mjs → spawnHappyCLI-D3EkTFIn.mjs} +1 -1
- package/dist/{ssh-DL_AkRAD.mjs → ssh-B0hvEYIl.mjs} +137 -103
- package/dist/{ssh-BTOlhhxi.cjs → ssh-CBgVwKsB.cjs} +142 -106
- package/dist/{task-CSpaC9HX.cjs → task-CMunPKCV.cjs} +6 -6
- package/dist/{task-CQzrHRZe.mjs → task-CRo6W-3_.mjs} +6 -6
- package/dist/{uninstall-nFMFGUVM.mjs → uninstall-BALE2up0.mjs} +2 -2
- package/dist/{uninstall-BiKyRAb1.cjs → uninstall-BJ3eEGM4.cjs} +2 -2
- package/dist/{update-Dw8pgsAT.cjs → update-Bb3iijy8.cjs} +3 -3
- package/dist/{update-BcqRHXf2.mjs → update-BfjtLrhO.mjs} +3 -3
- package/package.json +1 -1
- package/dist/deviceExec-CU1R9CRb.cjs +0 -84
- package/dist/deviceExec-D93AOVV4.mjs +0 -81
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
var child_process = require('child_process');
|
|
4
4
|
var path = require('node:path');
|
|
5
5
|
var projectPath = require('./projectPath-ChLhdOyt.cjs');
|
|
6
|
-
var logger = require('./persistence-
|
|
6
|
+
var logger = require('./persistence-DrfN7TzY.cjs');
|
|
7
7
|
var fs = require('node:fs');
|
|
8
8
|
|
|
9
9
|
let cachedRuntime = null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { p as projectPath } from './projectPath-BTBIMB57.mjs';
|
|
4
|
-
import { l as logger } from './persistence-
|
|
4
|
+
import { l as logger } from './persistence-CIUFyOwz.mjs';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
|
|
7
7
|
let cachedRuntime = null;
|
|
@@ -1,105 +1,27 @@
|
|
|
1
1
|
import { io } from 'socket.io-client';
|
|
2
|
-
import { c as configuration,
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import { hostname } from 'node:os';
|
|
10
|
-
import { decryptWithEphemeralKey } from './auth-BE8dD20T.mjs';
|
|
2
|
+
import { c as configuration, g as decrypt, h as decodeBase64, e as encodeBase64, f as encrypt } from './persistence-CIUFyOwz.mjs';
|
|
3
|
+
import { e as ensureDeviceKey, l as listDevices, r as readCachedDeviceNames, a as readCachedDeviceKey, p as pruneDeviceAliases, d as deviceExec } from './deviceKeys-CejCdWpw.mjs';
|
|
4
|
+
import { execOnDeviceViaDaemon } from './controlClient-DgjVRN8D.mjs';
|
|
5
|
+
import 'node:fs/promises';
|
|
6
|
+
import 'node:fs';
|
|
7
|
+
import 'node:os';
|
|
8
|
+
import 'node:path';
|
|
11
9
|
import 'zod';
|
|
12
10
|
import 'node:crypto';
|
|
11
|
+
import 'tweetnacl';
|
|
13
12
|
import 'chalk';
|
|
14
13
|
import 'fs';
|
|
14
|
+
import 'axios';
|
|
15
|
+
import './auth-DS8fzlin.mjs';
|
|
15
16
|
import 'qrcode-terminal';
|
|
16
17
|
import './time-DgsBLJA_.mjs';
|
|
17
|
-
import './browser-
|
|
18
|
+
import './browser-C05Ak6RL.mjs';
|
|
18
19
|
import 'open';
|
|
19
20
|
import 'react';
|
|
20
21
|
import 'ink';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function deviceKeysFile() {
|
|
25
|
-
return join(configuration.happyHomeDir, "device-keys.json");
|
|
26
|
-
}
|
|
27
|
-
async function readDeviceKeys() {
|
|
28
|
-
const file = deviceKeysFile();
|
|
29
|
-
if (!existsSync(file)) return {};
|
|
30
|
-
try {
|
|
31
|
-
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
32
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
33
|
-
} catch {
|
|
34
|
-
return {};
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
async function writeDeviceDirectory(entries) {
|
|
38
|
-
const file = deviceKeysFile();
|
|
39
|
-
await mkdir(dirname(file), { recursive: true });
|
|
40
|
-
const keys = await readDeviceKeys();
|
|
41
|
-
for (const entry of entries) {
|
|
42
|
-
keys[entry.id] = { name: entry.name, key: entry.key, variant: entry.variant ?? "dataKey" };
|
|
43
|
-
}
|
|
44
|
-
await writeFile(file, JSON.stringify(keys, null, 2), { mode: 384 });
|
|
45
|
-
}
|
|
46
|
-
async function readCachedDeviceKey(machineId) {
|
|
47
|
-
const keys = await readDeviceKeys();
|
|
48
|
-
const stored = keys[machineId];
|
|
49
|
-
if (!stored?.key) return null;
|
|
50
|
-
return { key: decodeBase64(stored.key), variant: stored.variant ?? "dataKey" };
|
|
51
|
-
}
|
|
52
|
-
async function readCachedDeviceNames() {
|
|
53
|
-
const keys = await readDeviceKeys();
|
|
54
|
-
return Object.fromEntries(Object.entries(keys).map(([id, entry]) => [id, entry.name]));
|
|
55
|
-
}
|
|
56
|
-
async function ensureDeviceKey(credentials, machineId, onPending) {
|
|
57
|
-
const cached = await readCachedDeviceKey(machineId);
|
|
58
|
-
if (cached) return cached;
|
|
59
|
-
const seed = tweetnacl.randomBytes(32);
|
|
60
|
-
const secretKey = tweetnacl.hash(seed).slice(0, 32);
|
|
61
|
-
const publicKey = tweetnacl.box.keyPair.fromSecretKey(secretKey).publicKey;
|
|
62
|
-
const created = await axios.post(
|
|
63
|
-
`${configuration.serverUrl}/v1/devices/key-requests`,
|
|
64
|
-
{ machineId, publicKey: encodeBase64(publicKey), label: `happy ssh from ${hostname()}` },
|
|
65
|
-
{ headers: { Authorization: `Bearer ${credentials.token}` }, timeout: 15e3 }
|
|
66
|
-
);
|
|
67
|
-
onPending?.({ id: created.data.id });
|
|
68
|
-
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
69
|
-
while (Date.now() < deadline) {
|
|
70
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
71
|
-
const status = await axios.get(
|
|
72
|
-
`${configuration.serverUrl}/v1/devices/key-requests/${created.data.id}`,
|
|
73
|
-
{ headers: { Authorization: `Bearer ${credentials.token}` }, timeout: 15e3 }
|
|
74
|
-
);
|
|
75
|
-
if (status.data.expired) {
|
|
76
|
-
throw new Error("Authorization request expired. Run the command again.");
|
|
77
|
-
}
|
|
78
|
-
if (status.data.approved && status.data.response) {
|
|
79
|
-
const opened = decryptWithEphemeralKey(decodeBase64(status.data.response), secretKey);
|
|
80
|
-
if (!opened) {
|
|
81
|
-
throw new Error("Could not open the device directory returned by the app");
|
|
82
|
-
}
|
|
83
|
-
let parsed;
|
|
84
|
-
try {
|
|
85
|
-
parsed = JSON.parse(Buffer.from(opened).toString("utf8"));
|
|
86
|
-
} catch {
|
|
87
|
-
throw new Error("Device directory from the app was malformed");
|
|
88
|
-
}
|
|
89
|
-
const devices = parsed.devices ?? [];
|
|
90
|
-
if (devices.length === 0) {
|
|
91
|
-
throw new Error("The app returned an empty device directory");
|
|
92
|
-
}
|
|
93
|
-
await writeDeviceDirectory(devices);
|
|
94
|
-
const target = devices.find((device) => device.id === machineId);
|
|
95
|
-
if (!target) {
|
|
96
|
-
throw new Error("The app did not include this device in the directory");
|
|
97
|
-
}
|
|
98
|
-
return { key: decodeBase64(target.key), variant: target.variant ?? "dataKey" };
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
throw new Error("Timed out waiting for approval in the Happy app");
|
|
102
|
-
}
|
|
22
|
+
import './projectPath-BTBIMB57.mjs';
|
|
23
|
+
import 'path';
|
|
24
|
+
import 'url';
|
|
103
25
|
|
|
104
26
|
function matchDevice(devices, query) {
|
|
105
27
|
const lowered = query.trim().toLowerCase();
|
|
@@ -112,7 +34,12 @@ function parseSshArgs(args) {
|
|
|
112
34
|
const command = separator >= 0 ? args.slice(separator + 1).join(" ") : null;
|
|
113
35
|
let target = null;
|
|
114
36
|
let timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS;
|
|
37
|
+
let stream = false;
|
|
115
38
|
for (let i = 0; i < head.length; i++) {
|
|
39
|
+
if (head[i] === "--stream" || head[i] === "-s") {
|
|
40
|
+
stream = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
116
43
|
if (head[i] === "--timeout" || head[i] === "-t") {
|
|
117
44
|
const seconds = Number(head[++i]);
|
|
118
45
|
if (Number.isFinite(seconds) && seconds > 0) timeoutMs = seconds * 1e3;
|
|
@@ -120,22 +47,134 @@ function parseSshArgs(args) {
|
|
|
120
47
|
}
|
|
121
48
|
if (!target && !head[i].startsWith("-")) target = head[i];
|
|
122
49
|
}
|
|
123
|
-
return { target, command: command && command.trim() ? command : null, timeoutMs };
|
|
50
|
+
return { target, command: command && command.trim() ? command : null, timeoutMs, stream };
|
|
124
51
|
}
|
|
125
|
-
async function
|
|
52
|
+
async function resolveFromCache(query) {
|
|
53
|
+
const names = await readCachedDeviceNames();
|
|
54
|
+
const id = pickCachedDeviceId(names, query);
|
|
55
|
+
if (!id) return null;
|
|
56
|
+
const material = await readCachedDeviceKey(id);
|
|
57
|
+
return material ? { device: { id, name: names[id] }, ...material } : null;
|
|
58
|
+
}
|
|
59
|
+
function pickCachedDeviceId(names, query) {
|
|
60
|
+
if (names[query]) return query;
|
|
61
|
+
const lowered = query.trim().toLowerCase();
|
|
62
|
+
for (const test of [
|
|
63
|
+
(name) => name === lowered,
|
|
64
|
+
(name) => name.startsWith(lowered),
|
|
65
|
+
(name) => name.includes(lowered)
|
|
66
|
+
]) {
|
|
67
|
+
const matches = Object.keys(names).filter((id) => test(names[id].toLowerCase()));
|
|
68
|
+
if (matches.length === 1) return matches[0];
|
|
69
|
+
if (matches.length > 1) return null;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
async function resolveFromServer(credentials, query) {
|
|
126
74
|
const { devices } = await listDevicesWithNames(credentials);
|
|
127
75
|
const device = matchDevice(devices, query);
|
|
128
76
|
if (!device) {
|
|
129
77
|
console.error(`No enrolled device matches "${query}". Run "happy ssh" with no arguments to list devices.`);
|
|
130
|
-
return
|
|
78
|
+
return null;
|
|
131
79
|
}
|
|
132
80
|
if (!device.active) {
|
|
133
81
|
console.error(`Device "${device.name}" is offline.`);
|
|
134
|
-
return
|
|
82
|
+
return null;
|
|
135
83
|
}
|
|
136
84
|
const { key, variant } = await ensureDeviceKey(credentials, device.id, () => {
|
|
137
85
|
console.error("Waiting for approval in the Happy app (Devices \u2192 pending request)...");
|
|
138
86
|
});
|
|
87
|
+
await pruneDeviceAliases(device.id, device.name);
|
|
88
|
+
return { device, key, variant };
|
|
89
|
+
}
|
|
90
|
+
async function resolveDeviceForCommand(credentials, query) {
|
|
91
|
+
return await resolveFromCache(query) ?? await resolveFromServer(credentials, query);
|
|
92
|
+
}
|
|
93
|
+
async function runOnDevice(credentials, query, command, options = {}) {
|
|
94
|
+
const cached = await resolveFromCache(query);
|
|
95
|
+
const resolved = cached ?? await resolveFromServer(credentials, query);
|
|
96
|
+
if (!resolved) return 1;
|
|
97
|
+
if (options.stream) {
|
|
98
|
+
return await streamOnResolved(credentials, resolved, command, options);
|
|
99
|
+
}
|
|
100
|
+
const viaDaemon = await execOnDeviceViaDaemon(
|
|
101
|
+
resolved.device.id,
|
|
102
|
+
command,
|
|
103
|
+
options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
104
|
+
).catch(() => null);
|
|
105
|
+
if (viaDaemon) {
|
|
106
|
+
return reportResult(viaDaemon);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return await execOnResolved(credentials, resolved, command, options);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!cached) throw error;
|
|
112
|
+
const fresh = await resolveFromServer(credentials, query);
|
|
113
|
+
if (!fresh) return 1;
|
|
114
|
+
return await execOnResolved(credentials, fresh, command, options);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function reportResult(result) {
|
|
118
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
119
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
120
|
+
if (result.error && !result.error.startsWith("Command failed:")) {
|
|
121
|
+
console.error(result.error);
|
|
122
|
+
}
|
|
123
|
+
return result.exitCode ?? (result.success ? 0 : 1);
|
|
124
|
+
}
|
|
125
|
+
async function streamOnResolved(credentials, { device, key, variant }, command, options) {
|
|
126
|
+
const socket = io(configuration.serverUrl, {
|
|
127
|
+
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
128
|
+
path: "/v1/updates",
|
|
129
|
+
transports: ["websocket"]
|
|
130
|
+
});
|
|
131
|
+
await new Promise((resolve, reject) => {
|
|
132
|
+
socket.once("connect", () => resolve());
|
|
133
|
+
socket.once("connect_error", reject);
|
|
134
|
+
});
|
|
135
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
136
|
+
try {
|
|
137
|
+
const answer = await socket.timeout(3e4).emitWithAck("rpc-call", {
|
|
138
|
+
method: `${device.id}:pty-start`,
|
|
139
|
+
params: encodeBase64(encrypt(key, variant, {
|
|
140
|
+
cols: process.stdout.columns || 80,
|
|
141
|
+
rows: process.stdout.rows || 24,
|
|
142
|
+
exec: { command, timeoutMs }
|
|
143
|
+
}))
|
|
144
|
+
});
|
|
145
|
+
if (!answer?.ok) throw new Error(answer?.error || "pty-start failed");
|
|
146
|
+
const started = decrypt(key, variant, decodeBase64(answer.result));
|
|
147
|
+
if (!started?.ok || !started.ptyId) {
|
|
148
|
+
throw new Error(started?.error ?? "Device refused to start the command");
|
|
149
|
+
}
|
|
150
|
+
return await new Promise((resolve) => {
|
|
151
|
+
const execId = started.ptyId;
|
|
152
|
+
const guard = setTimeout(() => resolve(1), timeoutMs + 3e4);
|
|
153
|
+
socket.on("pty-output", (envelope) => {
|
|
154
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== execId) return;
|
|
155
|
+
try {
|
|
156
|
+
const frame = decrypt(key, variant, decodeBase64(envelope.data));
|
|
157
|
+
if (typeof frame?.chunk !== "string") return;
|
|
158
|
+
const target = frame.stream === "stderr" ? process.stderr : process.stdout;
|
|
159
|
+
target.write(Buffer.from(frame.chunk, "base64"));
|
|
160
|
+
} catch {
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
socket.on("pty-exit", (envelope) => {
|
|
164
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== execId) return;
|
|
165
|
+
clearTimeout(guard);
|
|
166
|
+
resolve(typeof envelope.exitCode === "number" ? envelope.exitCode : 1);
|
|
167
|
+
});
|
|
168
|
+
socket.on("disconnect", () => {
|
|
169
|
+
clearTimeout(guard);
|
|
170
|
+
resolve(1);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
} finally {
|
|
174
|
+
socket.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function execOnResolved(credentials, { device, key, variant }, command, options) {
|
|
139
178
|
const socket = io(configuration.serverUrl, {
|
|
140
179
|
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
141
180
|
path: "/v1/updates",
|
|
@@ -153,12 +192,7 @@ async function runOnDevice(credentials, query, command, options = {}) {
|
|
|
153
192
|
// matchDevice already read the listing and rejected offline devices.
|
|
154
193
|
skipMachineLookup: true
|
|
155
194
|
});
|
|
156
|
-
|
|
157
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
158
|
-
if (result.error && !result.error.startsWith("Command failed:")) {
|
|
159
|
-
console.error(result.error);
|
|
160
|
-
}
|
|
161
|
-
return result.exitCode ?? (result.success ? 0 : 1);
|
|
195
|
+
return reportResult(result);
|
|
162
196
|
} finally {
|
|
163
197
|
socket.close();
|
|
164
198
|
}
|
|
@@ -325,4 +359,4 @@ async function sshPickAndConnect(credentials) {
|
|
|
325
359
|
return await sshDevice(credentials, chosen.id);
|
|
326
360
|
}
|
|
327
361
|
|
|
328
|
-
export { parseSshArgs, runOnDevice, sshDevice, sshPickAndConnect };
|
|
362
|
+
export { parseSshArgs, pickCachedDeviceId, resolveDeviceForCommand, runOnDevice, sshDevice, sshPickAndConnect };
|
|
@@ -1,107 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var socket_ioClient = require('socket.io-client');
|
|
4
|
-
var logger = require('./persistence-
|
|
5
|
-
var
|
|
6
|
-
var
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
var os = require('node:os');
|
|
12
|
-
var auth = require('./auth-BIleVCw0.cjs');
|
|
4
|
+
var logger = require('./persistence-DrfN7TzY.cjs');
|
|
5
|
+
var deviceKeys = require('./deviceKeys-BpIAWz13.cjs');
|
|
6
|
+
var controlClient = require('./controlClient-DgELDZgM.cjs');
|
|
7
|
+
require('node:fs/promises');
|
|
8
|
+
require('node:fs');
|
|
9
|
+
require('node:os');
|
|
10
|
+
require('node:path');
|
|
13
11
|
require('zod');
|
|
14
12
|
require('node:crypto');
|
|
13
|
+
require('tweetnacl');
|
|
15
14
|
require('chalk');
|
|
16
15
|
require('fs');
|
|
16
|
+
require('axios');
|
|
17
|
+
require('./auth-BQvEuSs5.cjs');
|
|
17
18
|
require('qrcode-terminal');
|
|
18
19
|
require('./time-Bj2HRkKH.cjs');
|
|
19
|
-
require('./browser-
|
|
20
|
+
require('./browser-C05SP_Tt.cjs');
|
|
20
21
|
require('open');
|
|
21
22
|
require('react');
|
|
22
23
|
require('ink');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
function deviceKeysFile() {
|
|
27
|
-
return path.join(logger.configuration.happyHomeDir, "device-keys.json");
|
|
28
|
-
}
|
|
29
|
-
async function readDeviceKeys() {
|
|
30
|
-
const file = deviceKeysFile();
|
|
31
|
-
if (!fs.existsSync(file)) return {};
|
|
32
|
-
try {
|
|
33
|
-
const parsed = JSON.parse(await promises.readFile(file, "utf8"));
|
|
34
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
35
|
-
} catch {
|
|
36
|
-
return {};
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
async function writeDeviceDirectory(entries) {
|
|
40
|
-
const file = deviceKeysFile();
|
|
41
|
-
await promises.mkdir(path.dirname(file), { recursive: true });
|
|
42
|
-
const keys = await readDeviceKeys();
|
|
43
|
-
for (const entry of entries) {
|
|
44
|
-
keys[entry.id] = { name: entry.name, key: entry.key, variant: entry.variant ?? "dataKey" };
|
|
45
|
-
}
|
|
46
|
-
await promises.writeFile(file, JSON.stringify(keys, null, 2), { mode: 384 });
|
|
47
|
-
}
|
|
48
|
-
async function readCachedDeviceKey(machineId) {
|
|
49
|
-
const keys = await readDeviceKeys();
|
|
50
|
-
const stored = keys[machineId];
|
|
51
|
-
if (!stored?.key) return null;
|
|
52
|
-
return { key: logger.decodeBase64(stored.key), variant: stored.variant ?? "dataKey" };
|
|
53
|
-
}
|
|
54
|
-
async function readCachedDeviceNames() {
|
|
55
|
-
const keys = await readDeviceKeys();
|
|
56
|
-
return Object.fromEntries(Object.entries(keys).map(([id, entry]) => [id, entry.name]));
|
|
57
|
-
}
|
|
58
|
-
async function ensureDeviceKey(credentials, machineId, onPending) {
|
|
59
|
-
const cached = await readCachedDeviceKey(machineId);
|
|
60
|
-
if (cached) return cached;
|
|
61
|
-
const seed = tweetnacl.randomBytes(32);
|
|
62
|
-
const secretKey = tweetnacl.hash(seed).slice(0, 32);
|
|
63
|
-
const publicKey = tweetnacl.box.keyPair.fromSecretKey(secretKey).publicKey;
|
|
64
|
-
const created = await axios.post(
|
|
65
|
-
`${logger.configuration.serverUrl}/v1/devices/key-requests`,
|
|
66
|
-
{ machineId, publicKey: logger.encodeBase64(publicKey), label: `happy ssh from ${os.hostname()}` },
|
|
67
|
-
{ headers: { Authorization: `Bearer ${credentials.token}` }, timeout: 15e3 }
|
|
68
|
-
);
|
|
69
|
-
onPending?.({ id: created.data.id });
|
|
70
|
-
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
71
|
-
while (Date.now() < deadline) {
|
|
72
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
73
|
-
const status = await axios.get(
|
|
74
|
-
`${logger.configuration.serverUrl}/v1/devices/key-requests/${created.data.id}`,
|
|
75
|
-
{ headers: { Authorization: `Bearer ${credentials.token}` }, timeout: 15e3 }
|
|
76
|
-
);
|
|
77
|
-
if (status.data.expired) {
|
|
78
|
-
throw new Error("Authorization request expired. Run the command again.");
|
|
79
|
-
}
|
|
80
|
-
if (status.data.approved && status.data.response) {
|
|
81
|
-
const opened = auth.decryptWithEphemeralKey(logger.decodeBase64(status.data.response), secretKey);
|
|
82
|
-
if (!opened) {
|
|
83
|
-
throw new Error("Could not open the device directory returned by the app");
|
|
84
|
-
}
|
|
85
|
-
let parsed;
|
|
86
|
-
try {
|
|
87
|
-
parsed = JSON.parse(Buffer.from(opened).toString("utf8"));
|
|
88
|
-
} catch {
|
|
89
|
-
throw new Error("Device directory from the app was malformed");
|
|
90
|
-
}
|
|
91
|
-
const devices = parsed.devices ?? [];
|
|
92
|
-
if (devices.length === 0) {
|
|
93
|
-
throw new Error("The app returned an empty device directory");
|
|
94
|
-
}
|
|
95
|
-
await writeDeviceDirectory(devices);
|
|
96
|
-
const target = devices.find((device) => device.id === machineId);
|
|
97
|
-
if (!target) {
|
|
98
|
-
throw new Error("The app did not include this device in the directory");
|
|
99
|
-
}
|
|
100
|
-
return { key: logger.decodeBase64(target.key), variant: target.variant ?? "dataKey" };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
throw new Error("Timed out waiting for approval in the Happy app");
|
|
104
|
-
}
|
|
24
|
+
require('./projectPath-ChLhdOyt.cjs');
|
|
25
|
+
require('path');
|
|
26
|
+
require('url');
|
|
105
27
|
|
|
106
28
|
function matchDevice(devices, query) {
|
|
107
29
|
const lowered = query.trim().toLowerCase();
|
|
@@ -114,7 +36,12 @@ function parseSshArgs(args) {
|
|
|
114
36
|
const command = separator >= 0 ? args.slice(separator + 1).join(" ") : null;
|
|
115
37
|
let target = null;
|
|
116
38
|
let timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS;
|
|
39
|
+
let stream = false;
|
|
117
40
|
for (let i = 0; i < head.length; i++) {
|
|
41
|
+
if (head[i] === "--stream" || head[i] === "-s") {
|
|
42
|
+
stream = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
118
45
|
if (head[i] === "--timeout" || head[i] === "-t") {
|
|
119
46
|
const seconds = Number(head[++i]);
|
|
120
47
|
if (Number.isFinite(seconds) && seconds > 0) timeoutMs = seconds * 1e3;
|
|
@@ -122,22 +49,134 @@ function parseSshArgs(args) {
|
|
|
122
49
|
}
|
|
123
50
|
if (!target && !head[i].startsWith("-")) target = head[i];
|
|
124
51
|
}
|
|
125
|
-
return { target, command: command && command.trim() ? command : null, timeoutMs };
|
|
52
|
+
return { target, command: command && command.trim() ? command : null, timeoutMs, stream };
|
|
126
53
|
}
|
|
127
|
-
async function
|
|
54
|
+
async function resolveFromCache(query) {
|
|
55
|
+
const names = await deviceKeys.readCachedDeviceNames();
|
|
56
|
+
const id = pickCachedDeviceId(names, query);
|
|
57
|
+
if (!id) return null;
|
|
58
|
+
const material = await deviceKeys.readCachedDeviceKey(id);
|
|
59
|
+
return material ? { device: { id, name: names[id] }, ...material } : null;
|
|
60
|
+
}
|
|
61
|
+
function pickCachedDeviceId(names, query) {
|
|
62
|
+
if (names[query]) return query;
|
|
63
|
+
const lowered = query.trim().toLowerCase();
|
|
64
|
+
for (const test of [
|
|
65
|
+
(name) => name === lowered,
|
|
66
|
+
(name) => name.startsWith(lowered),
|
|
67
|
+
(name) => name.includes(lowered)
|
|
68
|
+
]) {
|
|
69
|
+
const matches = Object.keys(names).filter((id) => test(names[id].toLowerCase()));
|
|
70
|
+
if (matches.length === 1) return matches[0];
|
|
71
|
+
if (matches.length > 1) return null;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
async function resolveFromServer(credentials, query) {
|
|
128
76
|
const { devices } = await listDevicesWithNames(credentials);
|
|
129
77
|
const device = matchDevice(devices, query);
|
|
130
78
|
if (!device) {
|
|
131
79
|
console.error(`No enrolled device matches "${query}". Run "happy ssh" with no arguments to list devices.`);
|
|
132
|
-
return
|
|
80
|
+
return null;
|
|
133
81
|
}
|
|
134
82
|
if (!device.active) {
|
|
135
83
|
console.error(`Device "${device.name}" is offline.`);
|
|
136
|
-
return
|
|
84
|
+
return null;
|
|
137
85
|
}
|
|
138
|
-
const { key, variant } = await ensureDeviceKey(credentials, device.id, () => {
|
|
86
|
+
const { key, variant } = await deviceKeys.ensureDeviceKey(credentials, device.id, () => {
|
|
139
87
|
console.error("Waiting for approval in the Happy app (Devices \u2192 pending request)...");
|
|
140
88
|
});
|
|
89
|
+
await deviceKeys.pruneDeviceAliases(device.id, device.name);
|
|
90
|
+
return { device, key, variant };
|
|
91
|
+
}
|
|
92
|
+
async function resolveDeviceForCommand(credentials, query) {
|
|
93
|
+
return await resolveFromCache(query) ?? await resolveFromServer(credentials, query);
|
|
94
|
+
}
|
|
95
|
+
async function runOnDevice(credentials, query, command, options = {}) {
|
|
96
|
+
const cached = await resolveFromCache(query);
|
|
97
|
+
const resolved = cached ?? await resolveFromServer(credentials, query);
|
|
98
|
+
if (!resolved) return 1;
|
|
99
|
+
if (options.stream) {
|
|
100
|
+
return await streamOnResolved(credentials, resolved, command, options);
|
|
101
|
+
}
|
|
102
|
+
const viaDaemon = await controlClient.execOnDeviceViaDaemon(
|
|
103
|
+
resolved.device.id,
|
|
104
|
+
command,
|
|
105
|
+
options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
106
|
+
).catch(() => null);
|
|
107
|
+
if (viaDaemon) {
|
|
108
|
+
return reportResult(viaDaemon);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return await execOnResolved(credentials, resolved, command, options);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!cached) throw error;
|
|
114
|
+
const fresh = await resolveFromServer(credentials, query);
|
|
115
|
+
if (!fresh) return 1;
|
|
116
|
+
return await execOnResolved(credentials, fresh, command, options);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function reportResult(result) {
|
|
120
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
121
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
122
|
+
if (result.error && !result.error.startsWith("Command failed:")) {
|
|
123
|
+
console.error(result.error);
|
|
124
|
+
}
|
|
125
|
+
return result.exitCode ?? (result.success ? 0 : 1);
|
|
126
|
+
}
|
|
127
|
+
async function streamOnResolved(credentials, { device, key, variant }, command, options) {
|
|
128
|
+
const socket = socket_ioClient.io(logger.configuration.serverUrl, {
|
|
129
|
+
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
130
|
+
path: "/v1/updates",
|
|
131
|
+
transports: ["websocket"]
|
|
132
|
+
});
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
socket.once("connect", () => resolve());
|
|
135
|
+
socket.once("connect_error", reject);
|
|
136
|
+
});
|
|
137
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
138
|
+
try {
|
|
139
|
+
const answer = await socket.timeout(3e4).emitWithAck("rpc-call", {
|
|
140
|
+
method: `${device.id}:pty-start`,
|
|
141
|
+
params: logger.encodeBase64(logger.encrypt(key, variant, {
|
|
142
|
+
cols: process.stdout.columns || 80,
|
|
143
|
+
rows: process.stdout.rows || 24,
|
|
144
|
+
exec: { command, timeoutMs }
|
|
145
|
+
}))
|
|
146
|
+
});
|
|
147
|
+
if (!answer?.ok) throw new Error(answer?.error || "pty-start failed");
|
|
148
|
+
const started = logger.decrypt(key, variant, logger.decodeBase64(answer.result));
|
|
149
|
+
if (!started?.ok || !started.ptyId) {
|
|
150
|
+
throw new Error(started?.error ?? "Device refused to start the command");
|
|
151
|
+
}
|
|
152
|
+
return await new Promise((resolve) => {
|
|
153
|
+
const execId = started.ptyId;
|
|
154
|
+
const guard = setTimeout(() => resolve(1), timeoutMs + 3e4);
|
|
155
|
+
socket.on("pty-output", (envelope) => {
|
|
156
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== execId) return;
|
|
157
|
+
try {
|
|
158
|
+
const frame = logger.decrypt(key, variant, logger.decodeBase64(envelope.data));
|
|
159
|
+
if (typeof frame?.chunk !== "string") return;
|
|
160
|
+
const target = frame.stream === "stderr" ? process.stderr : process.stdout;
|
|
161
|
+
target.write(Buffer.from(frame.chunk, "base64"));
|
|
162
|
+
} catch {
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
socket.on("pty-exit", (envelope) => {
|
|
166
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== execId) return;
|
|
167
|
+
clearTimeout(guard);
|
|
168
|
+
resolve(typeof envelope.exitCode === "number" ? envelope.exitCode : 1);
|
|
169
|
+
});
|
|
170
|
+
socket.on("disconnect", () => {
|
|
171
|
+
clearTimeout(guard);
|
|
172
|
+
resolve(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
socket.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function execOnResolved(credentials, { device, key, variant }, command, options) {
|
|
141
180
|
const socket = socket_ioClient.io(logger.configuration.serverUrl, {
|
|
142
181
|
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
143
182
|
path: "/v1/updates",
|
|
@@ -148,19 +187,14 @@ async function runOnDevice(credentials, query, command, options = {}) {
|
|
|
148
187
|
socket.once("connect_error", reject);
|
|
149
188
|
});
|
|
150
189
|
try {
|
|
151
|
-
const result = await
|
|
190
|
+
const result = await deviceKeys.deviceExec(socket, credentials, device.id, command, {
|
|
152
191
|
deviceKeyBase64: logger.encodeBase64(key),
|
|
153
192
|
deviceKeyVariant: variant,
|
|
154
193
|
timeout: options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
155
194
|
// matchDevice already read the listing and rejected offline devices.
|
|
156
195
|
skipMachineLookup: true
|
|
157
196
|
});
|
|
158
|
-
|
|
159
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
160
|
-
if (result.error && !result.error.startsWith("Command failed:")) {
|
|
161
|
-
console.error(result.error);
|
|
162
|
-
}
|
|
163
|
-
return result.exitCode ?? (result.success ? 0 : 1);
|
|
197
|
+
return reportResult(result);
|
|
164
198
|
} finally {
|
|
165
199
|
socket.close();
|
|
166
200
|
}
|
|
@@ -176,7 +210,7 @@ async function sshDevice(credentials, query) {
|
|
|
176
210
|
console.error(`Device "${device.name}" is offline.`);
|
|
177
211
|
return 1;
|
|
178
212
|
}
|
|
179
|
-
const { key, variant } = await ensureDeviceKey(credentials, device.id, () => {
|
|
213
|
+
const { key, variant } = await deviceKeys.ensureDeviceKey(credentials, device.id, () => {
|
|
180
214
|
console.log("Waiting for approval in the Happy app (Devices \u2192 pending request)...");
|
|
181
215
|
});
|
|
182
216
|
const socket = socket_ioClient.io(logger.configuration.serverUrl, {
|
|
@@ -300,7 +334,7 @@ function pickDevice(devices) {
|
|
|
300
334
|
});
|
|
301
335
|
}
|
|
302
336
|
async function listDevicesWithNames(credentials) {
|
|
303
|
-
const [devices, names] = await Promise.all([
|
|
337
|
+
const [devices, names] = await Promise.all([deviceKeys.listDevices(credentials), deviceKeys.readCachedDeviceNames()]);
|
|
304
338
|
let named = false;
|
|
305
339
|
const withNames = devices.map((device) => {
|
|
306
340
|
const cached = names[device.id];
|
|
@@ -328,6 +362,8 @@ async function sshPickAndConnect(credentials) {
|
|
|
328
362
|
}
|
|
329
363
|
|
|
330
364
|
exports.parseSshArgs = parseSshArgs;
|
|
365
|
+
exports.pickCachedDeviceId = pickCachedDeviceId;
|
|
366
|
+
exports.resolveDeviceForCommand = resolveDeviceForCommand;
|
|
331
367
|
exports.runOnDevice = runOnDevice;
|
|
332
368
|
exports.sshDevice = sshDevice;
|
|
333
369
|
exports.sshPickAndConnect = sshPickAndConnect;
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
var chalk = require('chalk');
|
|
4
4
|
var path = require('node:path');
|
|
5
|
-
var logger = require('./persistence-
|
|
6
|
-
var sessionList = require('./sessionList-
|
|
7
|
-
var sessionSummary = require('./sessionSummary-
|
|
5
|
+
var logger = require('./persistence-DrfN7TzY.cjs');
|
|
6
|
+
var sessionList = require('./sessionList-DmmtC7uR.cjs');
|
|
7
|
+
var sessionSummary = require('./sessionSummary-DGLoVOxd.cjs');
|
|
8
8
|
var workflowCommon = require('./workflowCommon-Bl568-rt.cjs');
|
|
9
9
|
require('node:fs/promises');
|
|
10
10
|
require('node:fs');
|
|
@@ -14,14 +14,14 @@ require('node:crypto');
|
|
|
14
14
|
require('tweetnacl');
|
|
15
15
|
require('fs');
|
|
16
16
|
require('axios');
|
|
17
|
-
require('./auth-
|
|
17
|
+
require('./auth-BQvEuSs5.cjs');
|
|
18
18
|
require('qrcode-terminal');
|
|
19
19
|
require('./time-Bj2HRkKH.cjs');
|
|
20
|
-
require('./browser-
|
|
20
|
+
require('./browser-C05SP_Tt.cjs');
|
|
21
21
|
require('open');
|
|
22
22
|
require('react');
|
|
23
23
|
require('ink');
|
|
24
|
-
require('./sessionKeyCache-
|
|
24
|
+
require('./sessionKeyCache-dDquDzn0.cjs');
|
|
25
25
|
|
|
26
26
|
function printTaskHelp() {
|
|
27
27
|
console.log(`
|