@zonease/happy 0.6.37 → 0.6.39
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/{types-DYCJXXdA.mjs → api-CIZKyfSn.mjs} +136 -1725
- package/dist/{types-BrTorNAy.cjs → api-CwOCQeLH.cjs} +317 -1961
- package/dist/auth-0VnlXT9f.mjs +181 -0
- package/dist/auth-BE8dD20T.mjs +270 -0
- package/dist/auth-BIleVCw0.cjs +274 -0
- package/dist/auth-Cas9DR3b.cjs +183 -0
- package/dist/browser-Cc08m7of.cjs +22 -0
- package/dist/browser-Dll5hxOV.mjs +20 -0
- package/dist/claudeLocal-CPT3zcIU.mjs +344 -0
- package/dist/claudeLocal-WkgLbl1a.cjs +352 -0
- package/dist/codex/happyMcpStdioBridge.cjs +4 -1
- package/dist/codex/happyMcpStdioBridge.mjs +4 -1
- package/dist/common-BdNgghUJ.mjs +75 -0
- package/dist/common-CZvSRU4p.cjs +83 -0
- package/dist/{config-D_g6ohaV.cjs → config-Draa-5XC.cjs} +21 -34
- package/dist/{config-DPn-cbpz.mjs → config-rTT4qTU5.mjs} +7 -20
- package/dist/connect-BfC1tYP_.mjs +679 -0
- package/dist/connect-CJZ73syK.cjs +681 -0
- package/dist/{constants-B4x1K7by.cjs → constants-E_UWYTo3.cjs} +2 -2
- package/dist/{constants-BO9OAFOT.mjs → constants-pmXZGibX.mjs} +1 -1
- package/dist/controlClient-CM_QcJnB.mjs +159 -0
- package/dist/controlClient-DZMIsKsT.cjs +168 -0
- package/dist/deviceExec-CU1R9CRb.cjs +84 -0
- package/dist/deviceExec-D93AOVV4.mjs +81 -0
- package/dist/diagnose-B3D1a_v0.cjs +60 -0
- package/dist/diagnose-CuRKXxt1.mjs +58 -0
- package/dist/doctor-C08m5Z9f.cjs +78 -0
- package/dist/doctor-Cya9YSA0.cjs +214 -0
- package/dist/doctor-DZYB6IVc.mjs +211 -0
- package/dist/doctor-ON7pN5G5.mjs +74 -0
- package/dist/downloadImage-DfSFLb-h.mjs +887 -0
- package/dist/downloadImage-MQMrUUxS.cjs +897 -0
- package/dist/enroll-BxlbVftP.cjs +115 -0
- package/dist/enroll-C52x8oly.mjs +113 -0
- package/dist/evidence-Ccd9iYcj.mjs +127 -0
- package/dist/evidence-CjWBG5rX.cjs +129 -0
- package/dist/formatImageMessage-B4lSxlk2.mjs +43 -0
- package/dist/formatImageMessage-DbyG8c2j.cjs +46 -0
- package/dist/github-DKcyF1zp.mjs +57 -0
- package/dist/github-h9GhJJ_C.cjs +59 -0
- package/dist/index-CDTNgfUf.cjs +578 -0
- package/dist/index-CPKdvi7J.mjs +554 -0
- package/dist/index.cjs +812 -40
- package/dist/index.mjs +813 -40
- package/dist/install-BokHRgFW.cjs +98 -0
- package/dist/install-C_cp8b2-.cjs +96 -0
- package/dist/install-DAeCn8ae.mjs +94 -0
- package/dist/install-oRAjVLjP.mjs +95 -0
- package/dist/lib.cjs +18 -12
- package/dist/lib.d.cts +16 -16
- package/dist/lib.d.mts +16 -16
- package/dist/lib.mjs +13 -7
- package/dist/{loadableToolOutput-lQH8Kb_w.mjs → loadableToolOutput-Cuygmd4K.mjs} +4 -2
- package/dist/{loadableToolOutput-CasonFIt.cjs → loadableToolOutput-DDCJjywt.cjs} +63 -61
- package/dist/mcpToolSchemas-CjeTfrAs.mjs +62 -0
- package/dist/mcpToolSchemas-DK42yoZ_.cjs +64 -0
- package/dist/persistence-BFjvtUad.mjs +944 -0
- package/dist/persistence-JLUn_Nou.cjs +996 -0
- package/dist/projectPath-BTBIMB57.mjs +10 -0
- package/dist/projectPath-ChLhdOyt.cjs +13 -0
- package/dist/prompt-BTWDZZO0.cjs +25 -0
- package/dist/prompt-BsAZsPbo.mjs +22 -0
- package/dist/release-BMEzNONE.mjs +168 -0
- package/dist/release-EhOJeAZe.cjs +171 -0
- package/dist/run-BDn2aE3Q.cjs +2152 -0
- package/dist/run-DBZqN6Kn.mjs +2128 -0
- package/dist/runClaude-DNHvERX0.mjs +3763 -0
- package/dist/runClaude-uaKxPZPm.cjs +3766 -0
- package/dist/{runCodex-BTt3fxG4.cjs → runCodex-82UrZLme.cjs} +169 -153
- package/dist/{runCodex-BXCB5trB.mjs → runCodex-C-RTM0wA.mjs} +36 -20
- package/dist/{runGemini-37HHbp9n.mjs → runGemini-Bm6-4Xm2.mjs} +34 -17
- package/dist/{runGemini-C-0nSFBv.cjs → runGemini-CXMr4Jwb.cjs} +212 -195
- package/dist/{runOneShot-CXhbFJ2j.cjs → runOneShot-1B6RMYT7.cjs} +31 -55
- package/dist/{runOneShot-USX8QPfE.mjs → runOneShot-ioARRHrx.mjs} +14 -38
- package/dist/{serve-DZAq-_1F.cjs → serve-BtdAhkIE.cjs} +35 -58
- package/dist/{serve-BRedJPzL.mjs → serve-CwibwU6N.mjs} +12 -35
- package/dist/session-2sU4Bu1R.mjs +108 -0
- package/dist/session-D--F87zL.cjs +110 -0
- package/dist/sessionKeyCache-4Tp8pJ3N.cjs +36 -0
- package/dist/sessionKeyCache-D2BwO91C.mjs +33 -0
- package/dist/sessionList-BCSw3qYt.mjs +51 -0
- package/dist/sessionList-ICJsjNtJ.cjs +54 -0
- package/dist/sessionSummary-Bkz52BPh.cjs +393 -0
- package/dist/sessionSummary-lHGBWQSb.mjs +387 -0
- package/dist/spawnHappyCLI-CoORX0YW.mjs +66 -0
- package/dist/spawnHappyCLI-D_V-AAoP.cjs +70 -0
- package/dist/ssh-BTOlhhxi.cjs +333 -0
- package/dist/ssh-DL_AkRAD.mjs +328 -0
- package/dist/systemdScope-BeOtAkhk.mjs +25 -0
- package/dist/systemdScope-DW3-UoKB.cjs +29 -0
- package/dist/task-CQzrHRZe.mjs +165 -0
- package/dist/task-CSpaC9HX.cjs +167 -0
- package/dist/time-Bj2HRkKH.cjs +33 -0
- package/dist/time-DgsBLJA_.mjs +29 -0
- package/dist/trimIdent-4gvk9Wk6.cjs +22 -0
- package/dist/trimIdent-BAjmv_TG.mjs +20 -0
- package/dist/types-BoJh4Qfv.mjs +57 -0
- package/dist/types-DWeB7aqh.cjs +59 -0
- package/dist/uninstall-BiKyRAb1.cjs +77 -0
- package/dist/uninstall-nFMFGUVM.mjs +75 -0
- package/dist/update-BcqRHXf2.mjs +109 -0
- package/dist/update-Dw8pgsAT.cjs +111 -0
- package/dist/workflowCommon-B0d01Y_a.mjs +56 -0
- package/dist/workflowCommon-Bl568-rt.cjs +62 -0
- package/dist/workflowReports-BEWuQesA.cjs +113 -0
- package/dist/workflowReports-Dd8-4-8f.mjs +108 -0
- package/package.json +1 -1
- package/dist/index-BlkHOkB3.cjs +0 -11312
- package/dist/index-DJ2VpGiR.mjs +0 -11272
- package/dist/mcpToolSchemas-CdPXJQmb.mjs +0 -175
- package/dist/mcpToolSchemas-f-kttjFw.cjs +0 -186
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var socket_ioClient = require('socket.io-client');
|
|
4
|
+
var logger = require('./persistence-JLUn_Nou.cjs');
|
|
5
|
+
var deviceExec = require('./deviceExec-CU1R9CRb.cjs');
|
|
6
|
+
var axios = require('axios');
|
|
7
|
+
var tweetnacl = require('tweetnacl');
|
|
8
|
+
var fs = require('node:fs');
|
|
9
|
+
var promises = require('node:fs/promises');
|
|
10
|
+
var path = require('node:path');
|
|
11
|
+
var os = require('node:os');
|
|
12
|
+
var auth = require('./auth-BIleVCw0.cjs');
|
|
13
|
+
require('zod');
|
|
14
|
+
require('node:crypto');
|
|
15
|
+
require('chalk');
|
|
16
|
+
require('fs');
|
|
17
|
+
require('qrcode-terminal');
|
|
18
|
+
require('./time-Bj2HRkKH.cjs');
|
|
19
|
+
require('./browser-Cc08m7of.cjs');
|
|
20
|
+
require('open');
|
|
21
|
+
require('react');
|
|
22
|
+
require('ink');
|
|
23
|
+
|
|
24
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
25
|
+
const POLL_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
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
|
+
}
|
|
105
|
+
|
|
106
|
+
function matchDevice(devices, query) {
|
|
107
|
+
const lowered = query.trim().toLowerCase();
|
|
108
|
+
return devices.find((device) => device.id === query) ?? devices.find((device) => device.name.toLowerCase() === lowered) ?? devices.find((device) => device.name.toLowerCase().startsWith(lowered)) ?? devices.find((device) => device.name.toLowerCase().includes(lowered)) ?? null;
|
|
109
|
+
}
|
|
110
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 3e5;
|
|
111
|
+
function parseSshArgs(args) {
|
|
112
|
+
const separator = args.indexOf("--");
|
|
113
|
+
const head = separator >= 0 ? args.slice(0, separator) : args;
|
|
114
|
+
const command = separator >= 0 ? args.slice(separator + 1).join(" ") : null;
|
|
115
|
+
let target = null;
|
|
116
|
+
let timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS;
|
|
117
|
+
for (let i = 0; i < head.length; i++) {
|
|
118
|
+
if (head[i] === "--timeout" || head[i] === "-t") {
|
|
119
|
+
const seconds = Number(head[++i]);
|
|
120
|
+
if (Number.isFinite(seconds) && seconds > 0) timeoutMs = seconds * 1e3;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!target && !head[i].startsWith("-")) target = head[i];
|
|
124
|
+
}
|
|
125
|
+
return { target, command: command && command.trim() ? command : null, timeoutMs };
|
|
126
|
+
}
|
|
127
|
+
async function runOnDevice(credentials, query, command, options = {}) {
|
|
128
|
+
const { devices } = await listDevicesWithNames(credentials);
|
|
129
|
+
const device = matchDevice(devices, query);
|
|
130
|
+
if (!device) {
|
|
131
|
+
console.error(`No enrolled device matches "${query}". Run "happy ssh" with no arguments to list devices.`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
if (!device.active) {
|
|
135
|
+
console.error(`Device "${device.name}" is offline.`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
const { key, variant } = await ensureDeviceKey(credentials, device.id, () => {
|
|
139
|
+
console.error("Waiting for approval in the Happy app (Devices \u2192 pending request)...");
|
|
140
|
+
});
|
|
141
|
+
const socket = socket_ioClient.io(logger.configuration.serverUrl, {
|
|
142
|
+
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
143
|
+
path: "/v1/updates",
|
|
144
|
+
transports: ["websocket"]
|
|
145
|
+
});
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
socket.once("connect", () => resolve());
|
|
148
|
+
socket.once("connect_error", reject);
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
const result = await deviceExec.deviceExec(socket, credentials, device.id, command, {
|
|
152
|
+
deviceKeyBase64: logger.encodeBase64(key),
|
|
153
|
+
deviceKeyVariant: variant,
|
|
154
|
+
timeout: options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
155
|
+
// matchDevice already read the listing and rejected offline devices.
|
|
156
|
+
skipMachineLookup: true
|
|
157
|
+
});
|
|
158
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
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);
|
|
164
|
+
} finally {
|
|
165
|
+
socket.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function sshDevice(credentials, query) {
|
|
169
|
+
const { devices } = await listDevicesWithNames(credentials);
|
|
170
|
+
const device = matchDevice(devices, query);
|
|
171
|
+
if (!device) {
|
|
172
|
+
console.error(`No enrolled device matches "${query}". Run "happy ssh" with no arguments to list devices.`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
if (!device.active) {
|
|
176
|
+
console.error(`Device "${device.name}" is offline.`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
const { key, variant } = await ensureDeviceKey(credentials, device.id, () => {
|
|
180
|
+
console.log("Waiting for approval in the Happy app (Devices \u2192 pending request)...");
|
|
181
|
+
});
|
|
182
|
+
const socket = socket_ioClient.io(logger.configuration.serverUrl, {
|
|
183
|
+
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
184
|
+
path: "/v1/updates",
|
|
185
|
+
transports: ["websocket"]
|
|
186
|
+
});
|
|
187
|
+
await new Promise((resolve, reject) => {
|
|
188
|
+
socket.once("connect", () => resolve());
|
|
189
|
+
socket.once("connect_error", reject);
|
|
190
|
+
});
|
|
191
|
+
const rpc = async (method, params) => {
|
|
192
|
+
const answer = await socket.timeout(3e4).emitWithAck("rpc-call", {
|
|
193
|
+
method: `${device.id}:${method}`,
|
|
194
|
+
params: logger.encodeBase64(logger.encrypt(key, variant, params))
|
|
195
|
+
});
|
|
196
|
+
if (!answer?.ok) throw new Error(answer?.error || `${method} failed`);
|
|
197
|
+
return logger.decrypt(key, variant, logger.decodeBase64(answer.result));
|
|
198
|
+
};
|
|
199
|
+
const cols = process.stdout.columns || 80;
|
|
200
|
+
const rows = process.stdout.rows || 24;
|
|
201
|
+
const started = await rpc("pty-start", { cols, rows });
|
|
202
|
+
if (!started?.ok || !started.ptyId) {
|
|
203
|
+
socket.close();
|
|
204
|
+
console.error(`Could not open a shell on "${device.name}": ${started?.error ?? "unknown error"}`);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const ptyId = started.ptyId;
|
|
208
|
+
let exitCode = 0;
|
|
209
|
+
const cleanup = () => {
|
|
210
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
211
|
+
process.stdin.pause();
|
|
212
|
+
socket.close();
|
|
213
|
+
};
|
|
214
|
+
await new Promise((resolve) => {
|
|
215
|
+
socket.on("pty-output", (envelope) => {
|
|
216
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== ptyId) return;
|
|
217
|
+
try {
|
|
218
|
+
const data = logger.decrypt(key, variant, logger.decodeBase64(envelope.data));
|
|
219
|
+
if (typeof data === "string") process.stdout.write(Buffer.from(data, "base64"));
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
socket.on("pty-exit", (envelope) => {
|
|
224
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== ptyId) return;
|
|
225
|
+
exitCode = typeof envelope.exitCode === "number" ? envelope.exitCode : 0;
|
|
226
|
+
resolve();
|
|
227
|
+
});
|
|
228
|
+
socket.on("disconnect", () => resolve());
|
|
229
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
230
|
+
process.stdin.resume();
|
|
231
|
+
process.stdin.on("data", (chunk) => {
|
|
232
|
+
socket.volatile.emit("pty-input", {
|
|
233
|
+
sessionId: device.id,
|
|
234
|
+
ptyId,
|
|
235
|
+
data: logger.encodeBase64(logger.encrypt(key, variant, chunk.toString("utf8")))
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
process.stdout.on("resize", () => {
|
|
239
|
+
void rpc("pty-resize", {
|
|
240
|
+
ptyId,
|
|
241
|
+
cols: process.stdout.columns || 80,
|
|
242
|
+
rows: process.stdout.rows || 24
|
|
243
|
+
}).catch(() => {
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
await rpc("pty-close", { ptyId }).catch(() => {
|
|
248
|
+
});
|
|
249
|
+
cleanup();
|
|
250
|
+
return exitCode;
|
|
251
|
+
}
|
|
252
|
+
function pickDevice(devices) {
|
|
253
|
+
const selectable = devices.filter((device) => device.active);
|
|
254
|
+
if (selectable.length === 0) {
|
|
255
|
+
console.log("No devices are online right now.");
|
|
256
|
+
return Promise.resolve(null);
|
|
257
|
+
}
|
|
258
|
+
if (selectable.length === 1) {
|
|
259
|
+
return Promise.resolve(selectable[0]);
|
|
260
|
+
}
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
let index = 0;
|
|
263
|
+
let rendered = 0;
|
|
264
|
+
const render = () => {
|
|
265
|
+
if (rendered > 0) process.stdout.write(`\x1B[${rendered}A`);
|
|
266
|
+
const lines = selectable.map((device, position) => {
|
|
267
|
+
const marker = position === index ? "\x1B[36m>\x1B[0m" : " ";
|
|
268
|
+
const name = position === index ? `\x1B[1m${device.name}\x1B[0m` : device.name;
|
|
269
|
+
return `\x1B[2K ${marker} ${name}${device.platform ? ` \x1B[2m${device.platform}\x1B[0m` : ""}`;
|
|
270
|
+
});
|
|
271
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
272
|
+
rendered = lines.length;
|
|
273
|
+
};
|
|
274
|
+
const finish = (device) => {
|
|
275
|
+
process.stdin.off("data", onData);
|
|
276
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
277
|
+
process.stdin.pause();
|
|
278
|
+
resolve(device);
|
|
279
|
+
};
|
|
280
|
+
const onData = (chunk) => {
|
|
281
|
+
const key = chunk.toString();
|
|
282
|
+
if (key === "\x1B[A" || key === "k") {
|
|
283
|
+
index = (index - 1 + selectable.length) % selectable.length;
|
|
284
|
+
render();
|
|
285
|
+
} else if (key === "\x1B[B" || key === "j") {
|
|
286
|
+
index = (index + 1) % selectable.length;
|
|
287
|
+
render();
|
|
288
|
+
} else if (key === "\r" || key === "\n") {
|
|
289
|
+
finish(selectable[index]);
|
|
290
|
+
} else if (key === "" || key === "\x1B" || key === "q") {
|
|
291
|
+
console.log("Cancelled.");
|
|
292
|
+
finish(null);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
console.log("Select a device (\u2191/\u2193, Enter to connect, q to cancel):");
|
|
296
|
+
render();
|
|
297
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
298
|
+
process.stdin.resume();
|
|
299
|
+
process.stdin.on("data", onData);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async function listDevicesWithNames(credentials) {
|
|
303
|
+
const [devices, names] = await Promise.all([deviceExec.listDevices(credentials), readCachedDeviceNames()]);
|
|
304
|
+
let named = false;
|
|
305
|
+
const withNames = devices.map((device) => {
|
|
306
|
+
const cached = names[device.id];
|
|
307
|
+
if (cached) named = true;
|
|
308
|
+
return cached ? { ...device, name: cached } : device;
|
|
309
|
+
});
|
|
310
|
+
return { devices: withNames, named };
|
|
311
|
+
}
|
|
312
|
+
async function sshPickAndConnect(credentials) {
|
|
313
|
+
const { devices } = await listDevicesWithNames(credentials);
|
|
314
|
+
if (devices.length === 0) {
|
|
315
|
+
console.log("No devices enrolled yet. Add one from Happy \u2192 Devices.");
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
if (!process.stdin.isTTY) {
|
|
319
|
+
for (const device of devices) {
|
|
320
|
+
console.log(`${device.active ? "online " : "offline"} ${device.name}${device.platform ? ` (${device.platform})` : ""}`);
|
|
321
|
+
}
|
|
322
|
+
console.log("\nUsage: happy ssh <device>");
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
const chosen = await pickDevice(devices);
|
|
326
|
+
if (!chosen) return 0;
|
|
327
|
+
return await sshDevice(credentials, chosen.id);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
exports.parseSshArgs = parseSshArgs;
|
|
331
|
+
exports.runOnDevice = runOnDevice;
|
|
332
|
+
exports.sshDevice = sshDevice;
|
|
333
|
+
exports.sshPickAndConnect = sshPickAndConnect;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { io } from 'socket.io-client';
|
|
2
|
+
import { c as configuration, f as encodeBase64, e as decodeBase64, h as decrypt, g as encrypt } from './persistence-BFjvtUad.mjs';
|
|
3
|
+
import { d as deviceExec, l as listDevices } from './deviceExec-D93AOVV4.mjs';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import tweetnacl from 'tweetnacl';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { hostname } from 'node:os';
|
|
10
|
+
import { decryptWithEphemeralKey } from './auth-BE8dD20T.mjs';
|
|
11
|
+
import 'zod';
|
|
12
|
+
import 'node:crypto';
|
|
13
|
+
import 'chalk';
|
|
14
|
+
import 'fs';
|
|
15
|
+
import 'qrcode-terminal';
|
|
16
|
+
import './time-DgsBLJA_.mjs';
|
|
17
|
+
import './browser-Dll5hxOV.mjs';
|
|
18
|
+
import 'open';
|
|
19
|
+
import 'react';
|
|
20
|
+
import 'ink';
|
|
21
|
+
|
|
22
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
23
|
+
const POLL_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
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
|
+
}
|
|
103
|
+
|
|
104
|
+
function matchDevice(devices, query) {
|
|
105
|
+
const lowered = query.trim().toLowerCase();
|
|
106
|
+
return devices.find((device) => device.id === query) ?? devices.find((device) => device.name.toLowerCase() === lowered) ?? devices.find((device) => device.name.toLowerCase().startsWith(lowered)) ?? devices.find((device) => device.name.toLowerCase().includes(lowered)) ?? null;
|
|
107
|
+
}
|
|
108
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 3e5;
|
|
109
|
+
function parseSshArgs(args) {
|
|
110
|
+
const separator = args.indexOf("--");
|
|
111
|
+
const head = separator >= 0 ? args.slice(0, separator) : args;
|
|
112
|
+
const command = separator >= 0 ? args.slice(separator + 1).join(" ") : null;
|
|
113
|
+
let target = null;
|
|
114
|
+
let timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS;
|
|
115
|
+
for (let i = 0; i < head.length; i++) {
|
|
116
|
+
if (head[i] === "--timeout" || head[i] === "-t") {
|
|
117
|
+
const seconds = Number(head[++i]);
|
|
118
|
+
if (Number.isFinite(seconds) && seconds > 0) timeoutMs = seconds * 1e3;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!target && !head[i].startsWith("-")) target = head[i];
|
|
122
|
+
}
|
|
123
|
+
return { target, command: command && command.trim() ? command : null, timeoutMs };
|
|
124
|
+
}
|
|
125
|
+
async function runOnDevice(credentials, query, command, options = {}) {
|
|
126
|
+
const { devices } = await listDevicesWithNames(credentials);
|
|
127
|
+
const device = matchDevice(devices, query);
|
|
128
|
+
if (!device) {
|
|
129
|
+
console.error(`No enrolled device matches "${query}". Run "happy ssh" with no arguments to list devices.`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
if (!device.active) {
|
|
133
|
+
console.error(`Device "${device.name}" is offline.`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const { key, variant } = await ensureDeviceKey(credentials, device.id, () => {
|
|
137
|
+
console.error("Waiting for approval in the Happy app (Devices \u2192 pending request)...");
|
|
138
|
+
});
|
|
139
|
+
const socket = io(configuration.serverUrl, {
|
|
140
|
+
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
141
|
+
path: "/v1/updates",
|
|
142
|
+
transports: ["websocket"]
|
|
143
|
+
});
|
|
144
|
+
await new Promise((resolve, reject) => {
|
|
145
|
+
socket.once("connect", () => resolve());
|
|
146
|
+
socket.once("connect_error", reject);
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const result = await deviceExec(socket, credentials, device.id, command, {
|
|
150
|
+
deviceKeyBase64: encodeBase64(key),
|
|
151
|
+
deviceKeyVariant: variant,
|
|
152
|
+
timeout: options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
153
|
+
// matchDevice already read the listing and rejected offline devices.
|
|
154
|
+
skipMachineLookup: true
|
|
155
|
+
});
|
|
156
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
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);
|
|
162
|
+
} finally {
|
|
163
|
+
socket.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function sshDevice(credentials, query) {
|
|
167
|
+
const { devices } = await listDevicesWithNames(credentials);
|
|
168
|
+
const device = matchDevice(devices, query);
|
|
169
|
+
if (!device) {
|
|
170
|
+
console.error(`No enrolled device matches "${query}". Run "happy ssh" with no arguments to list devices.`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
if (!device.active) {
|
|
174
|
+
console.error(`Device "${device.name}" is offline.`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
const { key, variant } = await ensureDeviceKey(credentials, device.id, () => {
|
|
178
|
+
console.log("Waiting for approval in the Happy app (Devices \u2192 pending request)...");
|
|
179
|
+
});
|
|
180
|
+
const socket = io(configuration.serverUrl, {
|
|
181
|
+
auth: { token: credentials.token, clientType: "user-scoped" },
|
|
182
|
+
path: "/v1/updates",
|
|
183
|
+
transports: ["websocket"]
|
|
184
|
+
});
|
|
185
|
+
await new Promise((resolve, reject) => {
|
|
186
|
+
socket.once("connect", () => resolve());
|
|
187
|
+
socket.once("connect_error", reject);
|
|
188
|
+
});
|
|
189
|
+
const rpc = async (method, params) => {
|
|
190
|
+
const answer = await socket.timeout(3e4).emitWithAck("rpc-call", {
|
|
191
|
+
method: `${device.id}:${method}`,
|
|
192
|
+
params: encodeBase64(encrypt(key, variant, params))
|
|
193
|
+
});
|
|
194
|
+
if (!answer?.ok) throw new Error(answer?.error || `${method} failed`);
|
|
195
|
+
return decrypt(key, variant, decodeBase64(answer.result));
|
|
196
|
+
};
|
|
197
|
+
const cols = process.stdout.columns || 80;
|
|
198
|
+
const rows = process.stdout.rows || 24;
|
|
199
|
+
const started = await rpc("pty-start", { cols, rows });
|
|
200
|
+
if (!started?.ok || !started.ptyId) {
|
|
201
|
+
socket.close();
|
|
202
|
+
console.error(`Could not open a shell on "${device.name}": ${started?.error ?? "unknown error"}`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
const ptyId = started.ptyId;
|
|
206
|
+
let exitCode = 0;
|
|
207
|
+
const cleanup = () => {
|
|
208
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
209
|
+
process.stdin.pause();
|
|
210
|
+
socket.close();
|
|
211
|
+
};
|
|
212
|
+
await new Promise((resolve) => {
|
|
213
|
+
socket.on("pty-output", (envelope) => {
|
|
214
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== ptyId) return;
|
|
215
|
+
try {
|
|
216
|
+
const data = decrypt(key, variant, decodeBase64(envelope.data));
|
|
217
|
+
if (typeof data === "string") process.stdout.write(Buffer.from(data, "base64"));
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
socket.on("pty-exit", (envelope) => {
|
|
222
|
+
if (envelope?.sessionId !== device.id || envelope?.ptyId !== ptyId) return;
|
|
223
|
+
exitCode = typeof envelope.exitCode === "number" ? envelope.exitCode : 0;
|
|
224
|
+
resolve();
|
|
225
|
+
});
|
|
226
|
+
socket.on("disconnect", () => resolve());
|
|
227
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
228
|
+
process.stdin.resume();
|
|
229
|
+
process.stdin.on("data", (chunk) => {
|
|
230
|
+
socket.volatile.emit("pty-input", {
|
|
231
|
+
sessionId: device.id,
|
|
232
|
+
ptyId,
|
|
233
|
+
data: encodeBase64(encrypt(key, variant, chunk.toString("utf8")))
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
process.stdout.on("resize", () => {
|
|
237
|
+
void rpc("pty-resize", {
|
|
238
|
+
ptyId,
|
|
239
|
+
cols: process.stdout.columns || 80,
|
|
240
|
+
rows: process.stdout.rows || 24
|
|
241
|
+
}).catch(() => {
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
await rpc("pty-close", { ptyId }).catch(() => {
|
|
246
|
+
});
|
|
247
|
+
cleanup();
|
|
248
|
+
return exitCode;
|
|
249
|
+
}
|
|
250
|
+
function pickDevice(devices) {
|
|
251
|
+
const selectable = devices.filter((device) => device.active);
|
|
252
|
+
if (selectable.length === 0) {
|
|
253
|
+
console.log("No devices are online right now.");
|
|
254
|
+
return Promise.resolve(null);
|
|
255
|
+
}
|
|
256
|
+
if (selectable.length === 1) {
|
|
257
|
+
return Promise.resolve(selectable[0]);
|
|
258
|
+
}
|
|
259
|
+
return new Promise((resolve) => {
|
|
260
|
+
let index = 0;
|
|
261
|
+
let rendered = 0;
|
|
262
|
+
const render = () => {
|
|
263
|
+
if (rendered > 0) process.stdout.write(`\x1B[${rendered}A`);
|
|
264
|
+
const lines = selectable.map((device, position) => {
|
|
265
|
+
const marker = position === index ? "\x1B[36m>\x1B[0m" : " ";
|
|
266
|
+
const name = position === index ? `\x1B[1m${device.name}\x1B[0m` : device.name;
|
|
267
|
+
return `\x1B[2K ${marker} ${name}${device.platform ? ` \x1B[2m${device.platform}\x1B[0m` : ""}`;
|
|
268
|
+
});
|
|
269
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
270
|
+
rendered = lines.length;
|
|
271
|
+
};
|
|
272
|
+
const finish = (device) => {
|
|
273
|
+
process.stdin.off("data", onData);
|
|
274
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
275
|
+
process.stdin.pause();
|
|
276
|
+
resolve(device);
|
|
277
|
+
};
|
|
278
|
+
const onData = (chunk) => {
|
|
279
|
+
const key = chunk.toString();
|
|
280
|
+
if (key === "\x1B[A" || key === "k") {
|
|
281
|
+
index = (index - 1 + selectable.length) % selectable.length;
|
|
282
|
+
render();
|
|
283
|
+
} else if (key === "\x1B[B" || key === "j") {
|
|
284
|
+
index = (index + 1) % selectable.length;
|
|
285
|
+
render();
|
|
286
|
+
} else if (key === "\r" || key === "\n") {
|
|
287
|
+
finish(selectable[index]);
|
|
288
|
+
} else if (key === "" || key === "\x1B" || key === "q") {
|
|
289
|
+
console.log("Cancelled.");
|
|
290
|
+
finish(null);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
console.log("Select a device (\u2191/\u2193, Enter to connect, q to cancel):");
|
|
294
|
+
render();
|
|
295
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
296
|
+
process.stdin.resume();
|
|
297
|
+
process.stdin.on("data", onData);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function listDevicesWithNames(credentials) {
|
|
301
|
+
const [devices, names] = await Promise.all([listDevices(credentials), readCachedDeviceNames()]);
|
|
302
|
+
let named = false;
|
|
303
|
+
const withNames = devices.map((device) => {
|
|
304
|
+
const cached = names[device.id];
|
|
305
|
+
if (cached) named = true;
|
|
306
|
+
return cached ? { ...device, name: cached } : device;
|
|
307
|
+
});
|
|
308
|
+
return { devices: withNames, named };
|
|
309
|
+
}
|
|
310
|
+
async function sshPickAndConnect(credentials) {
|
|
311
|
+
const { devices } = await listDevicesWithNames(credentials);
|
|
312
|
+
if (devices.length === 0) {
|
|
313
|
+
console.log("No devices enrolled yet. Add one from Happy \u2192 Devices.");
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
if (!process.stdin.isTTY) {
|
|
317
|
+
for (const device of devices) {
|
|
318
|
+
console.log(`${device.active ? "online " : "offline"} ${device.name}${device.platform ? ` (${device.platform})` : ""}`);
|
|
319
|
+
}
|
|
320
|
+
console.log("\nUsage: happy ssh <device>");
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
const chosen = await pickDevice(devices);
|
|
324
|
+
if (!chosen) return 0;
|
|
325
|
+
return await sshDevice(credentials, chosen.id);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { parseSshArgs, runOnDevice, sshDevice, sshPickAndConnect };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
const SERVICE_NAME = "happy-daemon.service";
|
|
5
|
+
function systemdScopeFor(isRoot) {
|
|
6
|
+
if (isRoot) {
|
|
7
|
+
return {
|
|
8
|
+
system: true,
|
|
9
|
+
unitPath: join("/etc/systemd/system", SERVICE_NAME),
|
|
10
|
+
systemctlFlag: "",
|
|
11
|
+
wantedBy: "multi-user.target"
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
system: false,
|
|
16
|
+
unitPath: join(os.homedir(), ".config", "systemd", "user", SERVICE_NAME),
|
|
17
|
+
systemctlFlag: "--user",
|
|
18
|
+
wantedBy: "default.target"
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function isRootUser() {
|
|
22
|
+
return typeof process.getuid === "function" && process.getuid() === 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { SERVICE_NAME as S, isRootUser as i, systemdScopeFor as s };
|