twokey 1.0.4 → 1.0.5
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/postinstall.js +21 -0
- package/bin/twokey.js +181 -36
- package/package.json +2 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
if (process.env.CI === "true" || process.env.TWOKEY_SKIP_POSTINSTALL === "1") {
|
|
8
|
+
process.exit(0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const cliPath = path.join(__dirname, "twokey.js");
|
|
14
|
+
|
|
15
|
+
const child = spawn(process.execPath, [cliPath, "--desktop", "--enable-autostart", "--quiet"], {
|
|
16
|
+
stdio: "ignore",
|
|
17
|
+
shell: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
child.on("error", () => process.exit(0));
|
|
21
|
+
child.on("close", () => process.exit(0));
|
package/bin/twokey.js
CHANGED
|
@@ -6,14 +6,16 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import readline from "node:readline";
|
|
8
8
|
|
|
9
|
-
const VERSION = "1.0.
|
|
9
|
+
const VERSION = process.env.npm_package_version || "1.0.5";
|
|
10
10
|
const DEFAULT_MODEL = process.env.TWOKEY_OLLAMA_MODEL || "qwen2.5:3b";
|
|
11
11
|
const DEFAULT_OLLAMA_URL = process.env.TWOKEY_OLLAMA_URL || "http://127.0.0.1:11434";
|
|
12
12
|
const LATEST_RELEASE_API = "https://api.github.com/repos/meinzeug/twokey/releases/latest";
|
|
13
13
|
const APPIMAGE_DIR = path.join(os.homedir(), ".local", "share", "twokey", "bin");
|
|
14
14
|
const APPIMAGE_PATH = path.join(APPIMAGE_DIR, "twokey-ai.AppImage");
|
|
15
|
+
const APPIMAGE_META_PATH = path.join(APPIMAGE_DIR, "twokey-ai.meta.json");
|
|
15
16
|
|
|
16
17
|
const args = process.argv.slice(2);
|
|
18
|
+
const QUIET = args.includes("--quiet");
|
|
17
19
|
|
|
18
20
|
if (args.includes("--help") || args.includes("-h")) {
|
|
19
21
|
printHelp();
|
|
@@ -26,13 +28,27 @@ if (args.includes("--version") || args.includes("-v")) {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
if (args.includes("--desktop")) {
|
|
29
|
-
launchDesktopApp().then((
|
|
30
|
-
if (
|
|
31
|
-
|
|
31
|
+
launchDesktopApp().then(async (startedCommand) => {
|
|
32
|
+
if (startedCommand) {
|
|
33
|
+
if (args.includes("--enable-autostart")) {
|
|
34
|
+
try {
|
|
35
|
+
await ensureUserService(startedCommand);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!QUIET) {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
console.warn(`Autostart setup skipped: ${message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!QUIET) {
|
|
44
|
+
console.log("TwoKey desktop app started in background.");
|
|
45
|
+
}
|
|
32
46
|
process.exit(0);
|
|
33
47
|
}
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
if (!QUIET) {
|
|
49
|
+
console.error("No native desktop binary found in PATH.");
|
|
50
|
+
console.error("Install the .deb/.AppImage release and ensure 'twokey-ai' is available in PATH.");
|
|
51
|
+
}
|
|
36
52
|
process.exit(1);
|
|
37
53
|
});
|
|
38
54
|
}
|
|
@@ -55,15 +71,29 @@ if (onceIndex >= 0) {
|
|
|
55
71
|
process.exit(1);
|
|
56
72
|
});
|
|
57
73
|
} else {
|
|
58
|
-
launchDesktopApp().then((
|
|
59
|
-
if (
|
|
60
|
-
|
|
74
|
+
launchDesktopApp().then(async (startedCommand) => {
|
|
75
|
+
if (startedCommand) {
|
|
76
|
+
if (args.includes("--enable-autostart")) {
|
|
77
|
+
try {
|
|
78
|
+
await ensureUserService(startedCommand);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!QUIET) {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
console.warn(`Autostart setup skipped: ${message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!QUIET) {
|
|
87
|
+
console.log("TwoKey desktop app started in background.");
|
|
88
|
+
}
|
|
61
89
|
process.exit(0);
|
|
62
90
|
}
|
|
63
91
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
92
|
+
if (!QUIET) {
|
|
93
|
+
console.error("Could not start desktop app.");
|
|
94
|
+
console.error("Tried system binaries and auto-download from GitHub Releases.");
|
|
95
|
+
console.error("Use 'twokey --cli' to run terminal mode.");
|
|
96
|
+
}
|
|
67
97
|
process.exit(1);
|
|
68
98
|
});
|
|
69
99
|
}
|
|
@@ -161,46 +191,45 @@ async function askOllama(prompt) {
|
|
|
161
191
|
}
|
|
162
192
|
|
|
163
193
|
async function launchDesktopApp() {
|
|
194
|
+
let appImageReady = false;
|
|
195
|
+
try {
|
|
196
|
+
appImageReady = await ensureLocalAppImage();
|
|
197
|
+
} catch {
|
|
198
|
+
appImageReady = false;
|
|
199
|
+
}
|
|
200
|
+
|
|
164
201
|
const candidates = [];
|
|
165
202
|
if (process.env.TWOKEY_DESKTOP_CMD) {
|
|
166
203
|
candidates.push(process.env.TWOKEY_DESKTOP_CMD);
|
|
167
204
|
}
|
|
168
|
-
|
|
205
|
+
if (appImageReady) {
|
|
206
|
+
candidates.push(APPIMAGE_PATH);
|
|
207
|
+
}
|
|
208
|
+
candidates.push("twokey-ai", "twokey-desktop");
|
|
169
209
|
|
|
170
210
|
for (const command of candidates) {
|
|
171
211
|
const started = await spawnDetached(command);
|
|
172
212
|
if (started) {
|
|
173
|
-
return
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const downloaded = await ensureLocalAppImage();
|
|
179
|
-
if (downloaded) {
|
|
180
|
-
return spawnDetached(APPIMAGE_PATH);
|
|
213
|
+
return command;
|
|
181
214
|
}
|
|
182
|
-
} catch {
|
|
183
|
-
return false;
|
|
184
215
|
}
|
|
185
216
|
|
|
186
|
-
return
|
|
217
|
+
return null;
|
|
187
218
|
}
|
|
188
219
|
|
|
189
220
|
async function ensureLocalAppImage() {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
221
|
+
await fs.promises.mkdir(APPIMAGE_DIR, { recursive: true });
|
|
222
|
+
|
|
223
|
+
const latestAsset = await resolveLatestAppImageAsset();
|
|
224
|
+
if (!latestAsset) {
|
|
225
|
+
return hasExecutable(APPIMAGE_PATH);
|
|
195
226
|
}
|
|
196
227
|
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
if (!assetUrl) {
|
|
200
|
-
return false;
|
|
228
|
+
if (await isCurrentAppImage(latestAsset)) {
|
|
229
|
+
return true;
|
|
201
230
|
}
|
|
202
231
|
|
|
203
|
-
const response = await fetch(
|
|
232
|
+
const response = await fetch(latestAsset.url, {
|
|
204
233
|
headers: {
|
|
205
234
|
"User-Agent": "twokey-cli",
|
|
206
235
|
Accept: "application/octet-stream",
|
|
@@ -215,10 +244,19 @@ async function ensureLocalAppImage() {
|
|
|
215
244
|
await fs.promises.writeFile(APPIMAGE_PATH, data, { mode: 0o755 });
|
|
216
245
|
|
|
217
246
|
await fs.promises.chmod(APPIMAGE_PATH, 0o755);
|
|
247
|
+
const meta = {
|
|
248
|
+
releaseTag: latestAsset.releaseTag,
|
|
249
|
+
assetName: latestAsset.name,
|
|
250
|
+
assetId: latestAsset.id,
|
|
251
|
+
assetSize: latestAsset.size,
|
|
252
|
+
assetUpdatedAt: latestAsset.updatedAt,
|
|
253
|
+
downloadedAt: new Date().toISOString(),
|
|
254
|
+
};
|
|
255
|
+
await fs.promises.writeFile(APPIMAGE_META_PATH, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
218
256
|
return true;
|
|
219
257
|
}
|
|
220
258
|
|
|
221
|
-
async function
|
|
259
|
+
async function resolveLatestAppImageAsset() {
|
|
222
260
|
const response = await fetch(LATEST_RELEASE_API, {
|
|
223
261
|
headers: {
|
|
224
262
|
"User-Agent": "twokey-cli",
|
|
@@ -236,7 +274,54 @@ async function resolveLatestAppImageUrl() {
|
|
|
236
274
|
(asset) => typeof asset?.name === "string" && asset.name.endsWith(".AppImage") && asset.name.includes("amd64"),
|
|
237
275
|
) || assets.find((asset) => typeof asset?.name === "string" && asset.name.endsWith(".AppImage"));
|
|
238
276
|
|
|
239
|
-
|
|
277
|
+
if (!appImage?.browser_download_url) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
releaseTag: typeof payload?.tag_name === "string" ? payload.tag_name : "unknown",
|
|
283
|
+
id: Number.isFinite(appImage.id) ? appImage.id : 0,
|
|
284
|
+
name: appImage.name,
|
|
285
|
+
size: Number.isFinite(appImage.size) ? appImage.size : 0,
|
|
286
|
+
updatedAt: typeof appImage.updated_at === "string" ? appImage.updated_at : "",
|
|
287
|
+
url: appImage.browser_download_url,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function isCurrentAppImage(latestAsset) {
|
|
292
|
+
if (!(await hasExecutable(APPIMAGE_PATH))) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const [meta, stats] = await Promise.all([readAppImageMeta(), fs.promises.stat(APPIMAGE_PATH)]);
|
|
297
|
+
if (!meta) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
meta.releaseTag === latestAsset.releaseTag
|
|
303
|
+
&& meta.assetId === latestAsset.id
|
|
304
|
+
&& meta.assetUpdatedAt === latestAsset.updatedAt
|
|
305
|
+
&& Number(meta.assetSize) === Number(stats.size)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function readAppImageMeta() {
|
|
310
|
+
try {
|
|
311
|
+
const content = await fs.promises.readFile(APPIMAGE_META_PATH, "utf8");
|
|
312
|
+
return JSON.parse(content);
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function hasExecutable(filePath) {
|
|
319
|
+
try {
|
|
320
|
+
await fs.promises.access(filePath, fs.constants.X_OK);
|
|
321
|
+
return true;
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
240
325
|
}
|
|
241
326
|
|
|
242
327
|
function spawnDetached(command) {
|
|
@@ -271,3 +356,63 @@ function printHelp() {
|
|
|
271
356
|
console.log("Without options, twokey starts the native desktop app in background.");
|
|
272
357
|
console.log("If no desktop binary is installed, twokey tries to download an AppImage from latest GitHub release.");
|
|
273
358
|
}
|
|
359
|
+
|
|
360
|
+
async function ensureUserService(command) {
|
|
361
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
362
|
+
const systemdDir = path.join(configHome, "systemd", "user");
|
|
363
|
+
const servicePath = path.join(systemdDir, "twokey.service");
|
|
364
|
+
await fs.promises.mkdir(systemdDir, { recursive: true });
|
|
365
|
+
|
|
366
|
+
const content = [
|
|
367
|
+
"[Unit]",
|
|
368
|
+
"Description=TwoKey Desktop Assistant",
|
|
369
|
+
"After=graphical-session.target",
|
|
370
|
+
"",
|
|
371
|
+
"[Service]",
|
|
372
|
+
`ExecStart=/bin/sh -lc ${shellEscape(command)}`,
|
|
373
|
+
"Restart=on-failure",
|
|
374
|
+
"RestartSec=3",
|
|
375
|
+
"",
|
|
376
|
+
"[Install]",
|
|
377
|
+
"WantedBy=default.target",
|
|
378
|
+
"",
|
|
379
|
+
].join("\n");
|
|
380
|
+
|
|
381
|
+
await fs.promises.writeFile(servicePath, content, "utf8");
|
|
382
|
+
|
|
383
|
+
await runSystemctlUser(["daemon-reload"]);
|
|
384
|
+
await runSystemctlUser(["enable", "--now", "twokey.service"]);
|
|
385
|
+
|
|
386
|
+
if (!QUIET) {
|
|
387
|
+
console.log("TwoKey systemd user service enabled: twokey.service");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function runSystemctlUser(argsList) {
|
|
392
|
+
return new Promise((resolve, reject) => {
|
|
393
|
+
const child = spawn("systemctl", ["--user", ...argsList], {
|
|
394
|
+
stdio: QUIET ? "ignore" : "pipe",
|
|
395
|
+
shell: false,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
let stderr = "";
|
|
399
|
+
if (child.stderr) {
|
|
400
|
+
child.stderr.on("data", (chunk) => {
|
|
401
|
+
stderr += String(chunk);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
child.on("error", (error) => reject(error));
|
|
406
|
+
child.on("close", (code) => {
|
|
407
|
+
if (code === 0) {
|
|
408
|
+
resolve();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
reject(new Error(stderr.trim() || `systemctl --user failed with code ${code}`));
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function shellEscape(value) {
|
|
417
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
418
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twokey",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Linux-first desktop AI assistant built with Tauri, React, and TypeScript.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"preview": "vite preview --host 127.0.0.1",
|
|
43
43
|
"lint": "npm run typecheck",
|
|
44
44
|
"test": "npm run typecheck",
|
|
45
|
+
"postinstall": "node ./bin/postinstall.js",
|
|
45
46
|
"prepublishOnly": "npm run build && npm pack --dry-run",
|
|
46
47
|
"tauri:dev": "GTK_MODULES='' LIBGL_ALWAYS_SOFTWARE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri dev",
|
|
47
48
|
"tauri:build": "GTK_MODULES='' LIBGL_ALWAYS_SOFTWARE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri build"
|