twokey 1.0.4 → 1.0.6

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.
@@ -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.3";
9
+ const VERSION = process.env.npm_package_version || "1.0.6";
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((started) => {
30
- if (started) {
31
- console.log("TwoKey desktop app started in background.");
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
- console.error("No native desktop binary found in PATH.");
35
- console.error("Install the .deb/.AppImage release and ensure 'twokey-ai' is available in PATH.");
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((started) => {
59
- if (started) {
60
- console.log("TwoKey desktop app started in background.");
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
- console.error("Could not start desktop app.");
65
- console.error("Tried system binaries and auto-download from GitHub Releases.");
66
- console.error("Use 'twokey --cli' to run terminal mode.");
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
- candidates.push("twokey-ai", "twokey-desktop", APPIMAGE_PATH);
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 true;
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 false;
217
+ return null;
187
218
  }
188
219
 
189
220
  async function ensureLocalAppImage() {
190
- try {
191
- await fs.promises.access(APPIMAGE_PATH, fs.constants.X_OK);
192
- return true;
193
- } catch {
194
- // Not installed yet.
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 fs.promises.mkdir(APPIMAGE_DIR, { recursive: true });
198
- const assetUrl = await resolveLatestAppImageUrl();
199
- if (!assetUrl) {
200
- return false;
228
+ if (await isCurrentAppImage(latestAsset)) {
229
+ return true;
201
230
  }
202
231
 
203
- const response = await fetch(assetUrl, {
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 resolveLatestAppImageUrl() {
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
- return appImage?.browser_download_url ?? null;
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.4",
3
+ "version": "1.0.6",
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"