kennel 0.2.25 → 0.2.27

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.
@@ -54,8 +54,8 @@ export async function dispatch(argv) {
54
54
  await viewer(['--static', ...args]);
55
55
  return;
56
56
  }
57
- if (command === 'share') {
58
- // Deliberate remote share (LAN default; --tailscale for outside the
57
+ if (command === 'share' || command === 'display') {
58
+ // Deliberate remote display (LAN default; --tailscale for outside the
59
59
  // house). Runs a second remote-scoped static server; the plain 5180
60
60
  // viewer stays loopback-only.
61
61
  const { share } = await import('../package/preview.js');
package/dist/cli/usage.js CHANGED
@@ -13,7 +13,7 @@ export function usage() {
13
13
  kennel open|o [folder] [--open] [--host <host>] [--port <port>] [--dry-run]
14
14
  knl <plugin-id|alias> [folder] [--item <id>] [--open] [--dry-run]
15
15
  kennel serve [folder] [--host <host>] [--port <port>] [--lan|--tailscale]
16
- kennel share [folder] [--lan|--tailscale] [--port <port>] [--dry-run]
16
+ kennel share|display [folder] [--lan|--tailscale] [--port <port>] [--dry-run]
17
17
  kennel dev [path] [--config <json-file>] [--port <port>] [--script <script>] [--dry-run] [-- <args...>]
18
18
  kennel extension install
19
19
  kennel mcp generate [path] [--config <json-file>] [--data-only|--function-only]
@@ -25,6 +25,12 @@ export function usage() {
25
25
  kennel deploy [path] [--config <json-file>] [legacy alias for publish]
26
26
  kennel dp [path] [--config <json-file>] [legacy alias for publish]
27
27
 
28
+ In a terminal, the viewer shows the folder name, repository name, and an action menu.
29
+ Use Up/Down to move and Enter to choose.
30
+ Shortcuts: o open, s share to mobile, q exit.
31
+ Esc or Ctrl+C also exits.
32
+ Without an interactive terminal, the viewer prints its URL without a menu.
33
+
28
34
  Installed plugin ids are commands, for example knl tasks or knl calendar.
29
35
  Plugins can declare extra aliases, for example knl mail for plugin id email.
30
36
  Use --item <id> for a plugin detail view. Built-in commands take priority.
@@ -7,3 +7,7 @@
7
7
  * sources, while staying type-checked against that source of truth.
8
8
  */
9
9
  export declare function share(input: string[]): Promise<void>;
10
+ export declare function startLanShare(target: {
11
+ root: string;
12
+ target?: string;
13
+ }): Promise<import("kennel-preview/terminal").PreviewShare>;
@@ -211,9 +211,9 @@ var init_document_kinds = __esm(() => {
211
211
  });
212
212
 
213
213
  // packages/preview/src/terminal.ts
214
- import { execFile } from "node:child_process";
214
+ import { execFile, execFileSync } from "node:child_process";
215
215
  import { basename } from "node:path";
216
- import { emitKeypressEvents } from "node:readline";
216
+ import { clearScreenDown, cursorTo, emitKeypressEvents } from "node:readline";
217
217
  function openPreview(url) {
218
218
  const command = process.platform === "darwin" ? "/usr/bin/open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
219
219
  execFile(command, [url], (error) => {
@@ -221,17 +221,39 @@ function openPreview(url) {
221
221
  console.error(`Could not open browser: ${error.message}`);
222
222
  });
223
223
  }
224
- function previewTerminal(url, target) {
224
+ function copyPreviewLink(url) {
225
+ const command = process.platform === "darwin" ? "/usr/bin/pbcopy" : process.platform === "win32" ? "clip" : process.env.WAYLAND_DISPLAY ? "wl-copy" : "xclip";
226
+ const args = command === "xclip" ? ["-selection", "clipboard"] : [];
227
+ return new Promise((done, fail) => {
228
+ const child = execFile(command, args, (error) => error ? fail(error) : done());
229
+ child.stdin?.on("error", fail);
230
+ child.stdin?.end(url);
231
+ });
232
+ }
233
+ function repositoryName(folder) {
234
+ try {
235
+ const root = execFileSync("git", ["-C", folder, "rev-parse", "--show-toplevel"], {
236
+ encoding: "utf8",
237
+ stdio: ["ignore", "pipe", "ignore"],
238
+ timeout: 1000
239
+ });
240
+ return terminalText(basename(root.trim()));
241
+ } catch {
242
+ return;
243
+ }
244
+ }
245
+ function previewTerminal(url, target, startShare) {
225
246
  url = new URL(url).href;
226
247
  const input = process.stdin;
227
248
  const output = process.stdout;
228
249
  const terminal = Boolean(output.isTTY && process.env.TERM !== "dumb");
229
250
  const interactive = terminal && Boolean(input.isTTY);
230
- const name = (basename(target.target ?? target.root) || target.root).replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
231
- const label = `(open ${name})`;
251
+ const name = terminalText(basename(target.target ?? target.root) || target.root);
252
+ const title = terminalText(basename(target.root) || target.root);
253
+ const repository = interactive ? repositoryName(target.root) : undefined;
232
254
  const color = terminal && process.env.NO_COLOR === undefined && output.hasColors();
233
- const accent = (text) => color ? `\x1B[36m${text}\x1B[39m` : text;
234
- const link = terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
255
+ const accent = (text) => color ? `\x1B[32m${text}\x1B[39m` : text;
256
+ const link = (label) => terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
235
257
  ${url}`;
236
258
  const wasRaw = Boolean(input.isRaw);
237
259
  const wasFlowing = input.readableFlowing === true;
@@ -240,6 +262,74 @@ function previewTerminal(url, target) {
240
262
  resolveClosed = resolve;
241
263
  });
242
264
  let finished = false;
265
+ let busy = false;
266
+ let selected = 0;
267
+ let status = "";
268
+ let share;
269
+ const render = () => {
270
+ if (!interactive || finished)
271
+ return;
272
+ cursorTo(output, 0, 0);
273
+ clearScreenDown(output);
274
+ const width = Math.max(1, (output.columns || 80) - 5);
275
+ const fit = (text) => {
276
+ const characters = Array.from(terminalText(text));
277
+ return characters.length > width ? characters.slice(0, width - 1).join("") + "…" : characters.join("");
278
+ };
279
+ const count = Math.max(1, Math.min(8, (output.rows || 24) - 12));
280
+ const start = Math.max(0, Math.min(selected - Math.floor(count / 2), actions.length - count));
281
+ const choices = actions.slice(start, start + count).map((item, index) => {
282
+ const label = fit(item.shortcut ? `[${item.shortcut}] ${item.label}` : item.label);
283
+ return start + index === selected ? accent(` › ${label}`) : ` ${label}`;
284
+ });
285
+ output.write([
286
+ ` \x1B[1m${link(fit(title))}\x1B[22m`,
287
+ ...repository ? [` ${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
288
+ ...target.target ? [` File: ${fit(name)}`] : [],
289
+ "",
290
+ ...choices,
291
+ "",
292
+ " ↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
293
+ ...actions.length > count ? [` ${selected + 1} / ${actions.length}`] : [],
294
+ ...status ? ["", ` ${status}`] : [],
295
+ ""
296
+ ].join(`
297
+ `));
298
+ };
299
+ const shareFolder = async () => {
300
+ status = "Preparing a password-protected Wi-Fi link…";
301
+ render();
302
+ try {
303
+ share ??= startShare();
304
+ let active;
305
+ try {
306
+ active = await share;
307
+ } catch (error) {
308
+ share = undefined;
309
+ throw error;
310
+ }
311
+ if (finished)
312
+ return;
313
+ status = `Wi-Fi link: ${active.url}
314
+ Username: ${active.username} · Password: ${active.password}
315
+ Password required. Send the password separately. Same Wi-Fi only.
316
+ Self-signed HTTPS (24 hours): expect a certificate warning.
317
+ Verify SHA-256 before trusting the certificate:
318
+ ${active.fingerprint}
319
+ Keep this command running. Ctrl+C stops sharing.`;
320
+ render();
321
+ try {
322
+ await copyPreviewLink(active.url);
323
+ status += `
324
+ Wi-Fi link copied to clipboard (without the password).`;
325
+ } catch (error) {
326
+ status += `
327
+ Could not copy link: ${terminalText(error instanceof Error ? error.message : String(error))}`;
328
+ }
329
+ } catch (error) {
330
+ status = `Could not share folder: ${terminalText(error instanceof Error ? error.message : String(error))}`;
331
+ }
332
+ };
243
333
  const close = () => {
244
334
  if (finished)
245
335
  return;
@@ -250,39 +340,83 @@ function previewTerminal(url, target) {
250
340
  if (interactive) {
251
341
  input.off("keypress", keypress);
252
342
  input.off("end", close);
343
+ output.off("resize", render);
344
+ output.write("\x1B[?25h\x1B[?1049l");
253
345
  input.setRawMode(wasRaw);
254
346
  if (!wasFlowing)
255
347
  input.pause();
256
348
  }
257
- resolveClosed();
349
+ if (share) {
350
+ share.then((active) => active.close()).catch((error) => {
351
+ console.error(` Wi-Fi share stopped: ${error instanceof Error ? error.message : error}`);
352
+ }).finally(() => resolveClosed());
353
+ } else
354
+ resolveClosed();
355
+ };
356
+ const actions = [
357
+ { label: "Open in browser", shortcut: "o", run: () => {
358
+ openPreview(url);
359
+ status = "Browser open requested.";
360
+ } },
361
+ { label: "Share to another device", shortcut: "s", run: shareFolder },
362
+ { label: "Exit", shortcut: "q", run: close }
363
+ ];
364
+ const activate = async () => {
365
+ busy = true;
366
+ try {
367
+ await actions[selected].run();
368
+ } catch (error) {
369
+ status = `Could not complete action: ${terminalText(error instanceof Error ? error.message : String(error))}`;
370
+ } finally {
371
+ busy = false;
372
+ render();
373
+ }
258
374
  };
259
375
  const keypress = (_text, key) => {
260
- if (key.ctrl && (key.name === "c" || key.name === "d"))
376
+ if (key.name === "escape" || key.ctrl && (key.name === "c" || key.name === "d")) {
261
377
  close();
262
- else if (key.name === "o" && !key.ctrl && !key.meta)
263
- openPreview(url);
378
+ return;
379
+ }
380
+ if (busy || finished)
381
+ return;
382
+ const shortcut = !key.ctrl && !key.meta ? actions.findIndex((item) => item.shortcut === key.name) : -1;
383
+ if (shortcut !== -1) {
384
+ selected = shortcut;
385
+ activate();
386
+ return;
387
+ }
388
+ if (key.name === "up")
389
+ selected = (selected - 1 + actions.length) % actions.length;
390
+ else if (key.name === "down")
391
+ selected = (selected + 1) % actions.length;
392
+ else if (key.name === "return") {
393
+ activate();
394
+ return;
395
+ } else
396
+ return;
397
+ render();
264
398
  };
265
399
  process.once("SIGINT", close);
266
400
  process.once("SIGTERM", close);
267
401
  process.once("exit", close);
268
402
  if (interactive) {
403
+ output.write("\x1B[?1049h\x1B[?25l");
404
+ output.on("resize", render);
269
405
  emitKeypressEvents(input);
270
406
  input.setRawMode(true);
271
407
  input.on("keypress", keypress);
272
408
  input.once("end", close);
273
409
  input.resume();
274
410
  }
275
- console.log(`
276
- ${link}
277
- `);
278
- if (interactive) {
279
- console.log(` shortcuts:
280
- ${accent("o")} - open in browser
281
- ctrl+c - exit
411
+ if (interactive)
412
+ render();
413
+ else
414
+ console.log(`
415
+ ${link(name)}
282
416
  `);
283
- }
284
417
  return { close, closed, interactive };
285
418
  }
419
+ var terminalText = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
286
420
  var init_terminal = () => {};
287
421
 
288
422
  // node_modules/.bun/openapi-fetch@0.17.0/node_modules/openapi-fetch/dist/index.mjs
@@ -6090,7 +6224,7 @@ var init_src = __esm(() => {
6090
6224
  });
6091
6225
 
6092
6226
  // packages/preview/src/hot-viewer.ts
6093
- import { execFileSync, spawn } from "node:child_process";
6227
+ import { execFileSync as execFileSync2, spawn } from "node:child_process";
6094
6228
  import { randomUUID } from "node:crypto";
6095
6229
  import { existsSync } from "node:fs";
6096
6230
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
@@ -6139,13 +6273,13 @@ function startupLock(host, port) {
6139
6273
  return resolve(tmpdir(), `kennel-hot-viewer-${name}.lock`);
6140
6274
  }
6141
6275
  function lanHost() {
6142
- const route = execFileSync("/sbin/route", ["-n", "get", "default"], {
6276
+ const route = execFileSync2("/sbin/route", ["-n", "get", "default"], {
6143
6277
  encoding: "utf8"
6144
6278
  });
6145
6279
  const device = route.match(/interface:\s+(\S+)/)?.[1];
6146
6280
  if (!device)
6147
6281
  throw new Error("kennel could not find the local network interface");
6148
- const host = execFileSync("/usr/sbin/ipconfig", ["getifaddr", device], {
6282
+ const host = execFileSync2("/usr/sbin/ipconfig", ["getifaddr", device], {
6149
6283
  encoding: "utf8"
6150
6284
  }).trim();
6151
6285
  if (!host)
@@ -6206,7 +6340,10 @@ async function hotViewer(options) {
6206
6340
  return;
6207
6341
  }
6208
6342
  const reuse = async () => {
6209
- const terminal = previewTerminal(viewerUrl, options);
6343
+ const terminal = previewTerminal(viewerUrl, options, async () => {
6344
+ await Promise.resolve().then(() => init_share());
6345
+ return startLanShare(options);
6346
+ });
6210
6347
  if (options.open)
6211
6348
  openPreview(viewerUrl);
6212
6349
  try {
@@ -6262,7 +6399,10 @@ async function hotViewer(options) {
6262
6399
  },
6263
6400
  stdio: ["ignore", "inherit", "inherit"]
6264
6401
  });
6265
- const terminal = previewTerminal(viewerUrl, options);
6402
+ const terminal = previewTerminal(viewerUrl, options, async () => {
6403
+ await Promise.resolve().then(() => init_share());
6404
+ return startLanShare(options);
6405
+ });
6266
6406
  terminal.closed.then(() => {
6267
6407
  if (child.exitCode === null && child.signalCode === null)
6268
6408
  child.kill("SIGTERM");
@@ -40336,24 +40476,12 @@ var init_server4 = __esm(() => {
40336
40476
  };
40337
40477
  });
40338
40478
 
40339
- // packages/preview/src/share.ts
40340
- import { resolve as resolve15 } from "node:path";
40341
- import { existsSync as existsSync7 } from "node:fs";
40342
-
40343
40479
  // packages/preview/src/viewer.ts
40344
- init_document_kinds();
40345
- init_terminal();
40346
- import { execFileSync as execFileSync2 } from "node:child_process";
40480
+ import { execFileSync as execFileSync3 } from "node:child_process";
40347
40481
  import { existsSync as existsSync6, realpathSync as realpathSync3, statSync as statSync3 } from "node:fs";
40348
40482
  import { createServer as createServer2 } from "node:net";
40349
40483
  import { dirname as dirname11, isAbsolute as isAbsolute8, relative as relative13, resolve as resolve14 } from "node:path";
40350
40484
  import { fileURLToPath as fileURLToPath3 } from "node:url";
40351
- var loopbackHosts = new Set([
40352
- "127.0.0.1",
40353
- "::1",
40354
- "::ffff:127.0.0.1",
40355
- "localhost"
40356
- ]);
40357
40485
  function isLoopbackHost(host) {
40358
40486
  return loopbackHosts.has(host) || host.startsWith("127.");
40359
40487
  }
@@ -40528,13 +40656,13 @@ async function availableViewerPort(start, host) {
40528
40656
  throw new Error(`Kennel could not find a free port at or above ${start}`);
40529
40657
  }
40530
40658
  function lanHost2() {
40531
- const route = execFileSync2("/sbin/route", ["-n", "get", "default"], {
40659
+ const route = execFileSync3("/sbin/route", ["-n", "get", "default"], {
40532
40660
  encoding: "utf8"
40533
40661
  });
40534
40662
  const device = route.match(/interface:\s+(\S+)/)?.[1];
40535
40663
  if (!device)
40536
40664
  throw new Error("kennel could not find the local network interface");
40537
- const host = execFileSync2("/usr/sbin/ipconfig", ["getifaddr", device], {
40665
+ const host = execFileSync3("/usr/sbin/ipconfig", ["getifaddr", device], {
40538
40666
  encoding: "utf8"
40539
40667
  }).trim();
40540
40668
  if (!host)
@@ -40542,7 +40670,7 @@ function lanHost2() {
40542
40670
  return host;
40543
40671
  }
40544
40672
  function tailscaleHost() {
40545
- const status = JSON.parse(execFileSync2("tailscale", ["status", "--json"], { encoding: "utf8" }));
40673
+ const status = JSON.parse(execFileSync3("tailscale", ["status", "--json"], { encoding: "utf8" }));
40546
40674
  const host = typeof status.Self?.DNSName === "string" ? status.Self.DNSName.replace(/\.$/, "") : "";
40547
40675
  if (host)
40548
40676
  return host;
@@ -40573,7 +40701,10 @@ async function viewer(input) {
40573
40701
  const existing = await runningViewer(initialViewerUrl);
40574
40702
  if (existing && isSameOrDescendant(existing.root, options.root)) {
40575
40703
  const viewerUrl = popUrl(displayHost, options.port, options);
40576
- const terminal = previewTerminal(viewerUrl, options);
40704
+ const terminal = previewTerminal(viewerUrl, options, async () => {
40705
+ await Promise.resolve().then(() => init_share());
40706
+ return startLanShare(options);
40707
+ });
40577
40708
  if (options.open)
40578
40709
  openPreview(viewerUrl);
40579
40710
  try {
@@ -40598,10 +40729,13 @@ async function viewer(input) {
40598
40729
  try {
40599
40730
  await api.listen(Number(options.port));
40600
40731
  if (options.tailscale) {
40601
- execFileSync2("tailscale", ["serve", "--bg", "--yes", options.port]);
40732
+ execFileSync3("tailscale", ["serve", "--bg", "--yes", options.port]);
40602
40733
  }
40603
40734
  const viewerUrl = popUrl(displayHost, options.port, options);
40604
- const terminal = previewTerminal(viewerUrl, options);
40735
+ const terminal = previewTerminal(viewerUrl, options, async () => {
40736
+ await Promise.resolve().then(() => init_share());
40737
+ return startLanShare(options);
40738
+ });
40605
40739
  if (options.open)
40606
40740
  openPreview(viewerUrl);
40607
40741
  try {
@@ -40613,12 +40747,132 @@ async function viewer(input) {
40613
40747
  await api.close();
40614
40748
  }
40615
40749
  if (options.tailscale) {
40616
- execFileSync2("tailscale", ["serve", "--https=443", "off"]);
40750
+ execFileSync3("tailscale", ["serve", "--https=443", "off"]);
40751
+ }
40752
+ }
40753
+ var loopbackHosts;
40754
+ var init_viewer = __esm(() => {
40755
+ init_document_kinds();
40756
+ init_terminal();
40757
+ loopbackHosts = new Set([
40758
+ "127.0.0.1",
40759
+ "::1",
40760
+ "::ffff:127.0.0.1",
40761
+ "localhost"
40762
+ ]);
40763
+ });
40764
+
40765
+ // packages/preview/src/protected-share.ts
40766
+ import { execFile as execFile6 } from "node:child_process";
40767
+ import { createHash as createHash6, randomBytes, timingSafeEqual, X509Certificate } from "node:crypto";
40768
+ import { mkdtemp, readFile as readFile11, rm as rm2 } from "node:fs/promises";
40769
+ import { createServer as createServer3 } from "node:https";
40770
+ import { isIP as isIP2 } from "node:net";
40771
+ import { tmpdir as tmpdir2 } from "node:os";
40772
+ import { join as join5 } from "node:path";
40773
+ import { promisify } from "node:util";
40774
+ async function previewCertificate(host) {
40775
+ if (!isIP2(host))
40776
+ throw new Error("The Wi-Fi share requires an IP address");
40777
+ const directory = await mkdtemp(join5(tmpdir2(), "kennel-preview-tls-"));
40778
+ const keyFile = join5(directory, "key.pem");
40779
+ const certFile = join5(directory, "cert.pem");
40780
+ try {
40781
+ await run("openssl", [
40782
+ "req",
40783
+ "-x509",
40784
+ "-newkey",
40785
+ "rsa:2048",
40786
+ "-nodes",
40787
+ "-days",
40788
+ "1",
40789
+ "-keyout",
40790
+ keyFile,
40791
+ "-out",
40792
+ certFile,
40793
+ "-subj",
40794
+ "/CN=Kennel Wi-Fi preview",
40795
+ "-addext",
40796
+ `subjectAltName=IP:${host}`
40797
+ ]);
40798
+ const [key, cert] = await Promise.all([readFile11(keyFile), readFile11(certFile)]);
40799
+ return { key, cert, fingerprint: new X509Certificate(cert).fingerprint256 };
40800
+ } catch (error) {
40801
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
40802
+ throw new Error("Password-protected sharing requires OpenSSL on PATH");
40803
+ }
40804
+ throw error;
40805
+ } finally {
40806
+ await rm2(directory, { recursive: true, force: true });
40807
+ }
40808
+ }
40809
+ async function startProtectedShare(host, handle) {
40810
+ const { key, cert, fingerprint } = await previewCertificate(host);
40811
+ const username = "kennel";
40812
+ const password = randomBytes(12).toString("base64url");
40813
+ const authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
40814
+ const expected = createHash6("sha256").update(authorization).digest();
40815
+ const attempts = new Map;
40816
+ const server = createServer3({ key, cert, minVersion: "TLSv1.2" }, (request, response) => {
40817
+ response.setHeader("Cache-Control", "no-store");
40818
+ response.setHeader("X-Content-Type-Options", "nosniff");
40819
+ const address = request.socket.remoteAddress ?? "unknown";
40820
+ const now = Date.now();
40821
+ for (const [ip, attempt] of attempts) {
40822
+ if (attempt.expires <= now)
40823
+ attempts.delete(ip);
40824
+ }
40825
+ const attempt = attempts.get(address);
40826
+ if (attempt && attempt.count >= 10 || !attempt && attempts.size >= 1024) {
40827
+ response.writeHead(429, { "Retry-After": "60" });
40828
+ response.end("Too many sign-in attempts. Try again in one minute.");
40829
+ return;
40830
+ }
40831
+ const supplied = createHash6("sha256").update(request.headers.authorization ?? "").digest();
40832
+ if (!timingSafeEqual(supplied, expected)) {
40833
+ attempts.set(address, { count: (attempt?.count ?? 0) + 1, expires: attempt?.expires ?? now + 60000 });
40834
+ response.writeHead(401, {
40835
+ "WWW-Authenticate": 'Basic realm="Kennel shared preview", charset="UTF-8"'
40836
+ });
40837
+ response.end("Sign in to the shared preview.");
40838
+ return;
40839
+ }
40840
+ handle(request, response);
40841
+ });
40842
+ const close = async () => {
40843
+ attempts.clear();
40844
+ await new Promise((done) => {
40845
+ server.close(() => done());
40846
+ server.closeAllConnections();
40847
+ });
40848
+ };
40849
+ try {
40850
+ await new Promise((done, fail) => {
40851
+ server.once("error", fail);
40852
+ server.listen(0, host, () => {
40853
+ server.off("error", fail);
40854
+ done();
40855
+ });
40856
+ });
40857
+ const address = server.address();
40858
+ if (!address || typeof address === "string")
40859
+ throw new Error("No Wi-Fi server address");
40860
+ return { port: address.port, username, password, authorization, cert, fingerprint, close };
40861
+ } catch (error) {
40862
+ await close();
40863
+ throw error;
40617
40864
  }
40618
40865
  }
40866
+ var run;
40867
+ var init_protected_share = __esm(() => {
40868
+ run = promisify(execFile6);
40869
+ });
40619
40870
 
40620
40871
  // packages/preview/src/share.ts
40621
- var plainViewerPort = "5180";
40872
+ import { dirname as dirname12, resolve as resolve15 } from "node:path";
40873
+ import { existsSync as existsSync7, realpathSync as realpathSync4 } from "node:fs";
40874
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
40875
+ import { get } from "node:https";
40622
40876
  function splitShareArgs(input) {
40623
40877
  const options = {
40624
40878
  folder: undefined,
@@ -40687,6 +40941,89 @@ async function runningViewerRoot(port = plainViewerPort) {
40687
40941
  }
40688
40942
  return;
40689
40943
  }
40944
+ async function startLanShare(target) {
40945
+ const root = realpathSync4(target.root);
40946
+ const web = resolve15(dirname12(fileURLToPath4(import.meta.url)), "../../web");
40947
+ if (!existsSync7(resolve15(web, "index.html"))) {
40948
+ throw new Error("Kennel web app not found. Run the CLI build first.");
40949
+ }
40950
+ const host = lanHost2();
40951
+ await Promise.resolve().then(() => init_server4());
40952
+ process.env.KENNEL_VIEWER_HOSTS = [process.env.KENNEL_VIEWER_HOSTS, host].filter(Boolean).join(",");
40953
+ const api = createKennelApi({
40954
+ root,
40955
+ host: "0.0.0.0",
40956
+ remoteAccess: true,
40957
+ restrictToRoot: true,
40958
+ lockedFile: target.target ? realpathSync4(target.target) : undefined,
40959
+ webRoot: web
40960
+ });
40961
+ let shared;
40962
+ const close = async () => {
40963
+ try {
40964
+ await api.close();
40965
+ } finally {
40966
+ await shared?.close();
40967
+ }
40968
+ };
40969
+ try {
40970
+ shared = await startProtectedShare(host, (request, response) => {
40971
+ api.handle(request, response);
40972
+ });
40973
+ const url = new URL(popUrl(host, String(shared.port), {
40974
+ dryRun: false,
40975
+ host: "0.0.0.0",
40976
+ hot: false,
40977
+ lan: true,
40978
+ open: false,
40979
+ root,
40980
+ port: String(shared.port),
40981
+ tailscale: false,
40982
+ watch: false,
40983
+ target: target.target
40984
+ }));
40985
+ url.protocol = "https:";
40986
+ const { cert, authorization } = shared;
40987
+ const health = await new Promise((done, fail) => {
40988
+ const request = get(new URL("/api/health", url), {
40989
+ ca: cert,
40990
+ agent: false,
40991
+ headers: { Authorization: authorization }
40992
+ }, (response) => {
40993
+ let body = "";
40994
+ response.setEncoding("utf8");
40995
+ response.on("error", fail);
40996
+ response.on("data", (chunk) => {
40997
+ body += chunk;
40998
+ });
40999
+ response.on("end", () => {
41000
+ try {
41001
+ if (response.statusCode !== 200)
41002
+ throw new Error("The Wi-Fi server did not respond");
41003
+ done(JSON.parse(body));
41004
+ } catch (error) {
41005
+ fail(error);
41006
+ }
41007
+ });
41008
+ });
41009
+ request.setTimeout(5000, () => request.destroy(new Error("The Wi-Fi server did not respond")));
41010
+ request.on("error", fail);
41011
+ });
41012
+ if (health.viewer !== "kennel-core" || health.root !== root) {
41013
+ throw new Error("The Wi-Fi server is not serving the selected folder");
41014
+ }
41015
+ return {
41016
+ url: url.href,
41017
+ username: shared.username,
41018
+ password: shared.password,
41019
+ fingerprint: shared.fingerprint,
41020
+ close
41021
+ };
41022
+ } catch (error) {
41023
+ await close();
41024
+ throw error;
41025
+ }
41026
+ }
40690
41027
  async function share(input) {
40691
41028
  const options = splitShareArgs(input);
40692
41029
  const folder = options.folder ?? await runningViewerRoot();
@@ -40708,9 +41045,17 @@ async function share(input) {
40708
41045
  folder
40709
41046
  ]);
40710
41047
  }
41048
+ var plainViewerPort = "5180";
41049
+ var init_share = __esm(() => {
41050
+ init_viewer();
41051
+ init_protected_share();
41052
+ });
41053
+ init_share();
41054
+
40711
41055
  export {
40712
41056
  plainViewerPort,
40713
41057
  runningViewerRoot,
40714
41058
  share,
40715
- splitShareArgs
41059
+ splitShareArgs,
41060
+ startLanShare
40716
41061
  };
@@ -1 +1,9 @@
1
- export { openPreview, previewTerminal } from "kennel-preview/terminal";
1
+ export { openPreview } from "kennel-preview/terminal";
2
+ export declare function previewTerminal(url: string, target: {
3
+ root: string;
4
+ target?: string;
5
+ }): {
6
+ close: () => void;
7
+ closed: Promise<void>;
8
+ interactive: boolean;
9
+ };
@@ -1,7 +1,7 @@
1
1
  // packages/preview/src/terminal.ts
2
- import { execFile } from "node:child_process";
2
+ import { execFile, execFileSync } from "node:child_process";
3
3
  import { basename } from "node:path";
4
- import { emitKeypressEvents } from "node:readline";
4
+ import { clearScreenDown, cursorTo, emitKeypressEvents } from "node:readline";
5
5
  function openPreview(url) {
6
6
  const command = process.platform === "darwin" ? "/usr/bin/open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
7
7
  execFile(command, [url], (error) => {
@@ -9,17 +9,40 @@ function openPreview(url) {
9
9
  console.error(`Could not open browser: ${error.message}`);
10
10
  });
11
11
  }
12
- function previewTerminal(url, target) {
12
+ function copyPreviewLink(url) {
13
+ const command = process.platform === "darwin" ? "/usr/bin/pbcopy" : process.platform === "win32" ? "clip" : process.env.WAYLAND_DISPLAY ? "wl-copy" : "xclip";
14
+ const args = command === "xclip" ? ["-selection", "clipboard"] : [];
15
+ return new Promise((done, fail) => {
16
+ const child = execFile(command, args, (error) => error ? fail(error) : done());
17
+ child.stdin?.on("error", fail);
18
+ child.stdin?.end(url);
19
+ });
20
+ }
21
+ var terminalText = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
22
+ function repositoryName(folder) {
23
+ try {
24
+ const root = execFileSync("git", ["-C", folder, "rev-parse", "--show-toplevel"], {
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "ignore"],
27
+ timeout: 1000
28
+ });
29
+ return terminalText(basename(root.trim()));
30
+ } catch {
31
+ return;
32
+ }
33
+ }
34
+ function previewTerminal(url, target, startShare) {
13
35
  url = new URL(url).href;
14
36
  const input = process.stdin;
15
37
  const output = process.stdout;
16
38
  const terminal = Boolean(output.isTTY && process.env.TERM !== "dumb");
17
39
  const interactive = terminal && Boolean(input.isTTY);
18
- const name = (basename(target.target ?? target.root) || target.root).replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
19
- const label = `(open ${name})`;
40
+ const name = terminalText(basename(target.target ?? target.root) || target.root);
41
+ const title = terminalText(basename(target.root) || target.root);
42
+ const repository = interactive ? repositoryName(target.root) : undefined;
20
43
  const color = terminal && process.env.NO_COLOR === undefined && output.hasColors();
21
- const accent = (text) => color ? `\x1B[36m${text}\x1B[39m` : text;
22
- const link = terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
44
+ const accent = (text) => color ? `\x1B[32m${text}\x1B[39m` : text;
45
+ const link = (label) => terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
23
46
  ${url}`;
24
47
  const wasRaw = Boolean(input.isRaw);
25
48
  const wasFlowing = input.readableFlowing === true;
@@ -28,6 +51,74 @@ function previewTerminal(url, target) {
28
51
  resolveClosed = resolve;
29
52
  });
30
53
  let finished = false;
54
+ let busy = false;
55
+ let selected = 0;
56
+ let status = "";
57
+ let share;
58
+ const render = () => {
59
+ if (!interactive || finished)
60
+ return;
61
+ cursorTo(output, 0, 0);
62
+ clearScreenDown(output);
63
+ const width = Math.max(1, (output.columns || 80) - 5);
64
+ const fit = (text) => {
65
+ const characters = Array.from(terminalText(text));
66
+ return characters.length > width ? characters.slice(0, width - 1).join("") + "…" : characters.join("");
67
+ };
68
+ const count = Math.max(1, Math.min(8, (output.rows || 24) - 12));
69
+ const start = Math.max(0, Math.min(selected - Math.floor(count / 2), actions.length - count));
70
+ const choices = actions.slice(start, start + count).map((item, index) => {
71
+ const label = fit(item.shortcut ? `[${item.shortcut}] ${item.label}` : item.label);
72
+ return start + index === selected ? accent(` › ${label}`) : ` ${label}`;
73
+ });
74
+ output.write([
75
+ ` \x1B[1m${link(fit(title))}\x1B[22m`,
76
+ ...repository ? [` ${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
77
+ ...target.target ? [` File: ${fit(name)}`] : [],
78
+ "",
79
+ ...choices,
80
+ "",
81
+ " ↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
82
+ ...actions.length > count ? [` ${selected + 1} / ${actions.length}`] : [],
83
+ ...status ? ["", ` ${status}`] : [],
84
+ ""
85
+ ].join(`
86
+ `));
87
+ };
88
+ const shareFolder = async () => {
89
+ status = "Preparing a password-protected Wi-Fi link…";
90
+ render();
91
+ try {
92
+ share ??= startShare();
93
+ let active;
94
+ try {
95
+ active = await share;
96
+ } catch (error) {
97
+ share = undefined;
98
+ throw error;
99
+ }
100
+ if (finished)
101
+ return;
102
+ status = `Wi-Fi link: ${active.url}
103
+ Username: ${active.username} · Password: ${active.password}
104
+ Password required. Send the password separately. Same Wi-Fi only.
105
+ Self-signed HTTPS (24 hours): expect a certificate warning.
106
+ Verify SHA-256 before trusting the certificate:
107
+ ${active.fingerprint}
108
+ Keep this command running. Ctrl+C stops sharing.`;
109
+ render();
110
+ try {
111
+ await copyPreviewLink(active.url);
112
+ status += `
113
+ Wi-Fi link copied to clipboard (without the password).`;
114
+ } catch (error) {
115
+ status += `
116
+ Could not copy link: ${terminalText(error instanceof Error ? error.message : String(error))}`;
117
+ }
118
+ } catch (error) {
119
+ status = `Could not share folder: ${terminalText(error instanceof Error ? error.message : String(error))}`;
120
+ }
121
+ };
31
122
  const close = () => {
32
123
  if (finished)
33
124
  return;
@@ -38,40 +129,91 @@ function previewTerminal(url, target) {
38
129
  if (interactive) {
39
130
  input.off("keypress", keypress);
40
131
  input.off("end", close);
132
+ output.off("resize", render);
133
+ output.write("\x1B[?25h\x1B[?1049l");
41
134
  input.setRawMode(wasRaw);
42
135
  if (!wasFlowing)
43
136
  input.pause();
44
137
  }
45
- resolveClosed();
138
+ if (share) {
139
+ share.then((active) => active.close()).catch((error) => {
140
+ console.error(` Wi-Fi share stopped: ${error instanceof Error ? error.message : error}`);
141
+ }).finally(() => resolveClosed());
142
+ } else
143
+ resolveClosed();
144
+ };
145
+ const actions = [
146
+ { label: "Open in browser", shortcut: "o", run: () => {
147
+ openPreview(url);
148
+ status = "Browser open requested.";
149
+ } },
150
+ { label: "Share to another device", shortcut: "s", run: shareFolder },
151
+ { label: "Exit", shortcut: "q", run: close }
152
+ ];
153
+ const activate = async () => {
154
+ busy = true;
155
+ try {
156
+ await actions[selected].run();
157
+ } catch (error) {
158
+ status = `Could not complete action: ${terminalText(error instanceof Error ? error.message : String(error))}`;
159
+ } finally {
160
+ busy = false;
161
+ render();
162
+ }
46
163
  };
47
164
  const keypress = (_text, key) => {
48
- if (key.ctrl && (key.name === "c" || key.name === "d"))
165
+ if (key.name === "escape" || key.ctrl && (key.name === "c" || key.name === "d")) {
49
166
  close();
50
- else if (key.name === "o" && !key.ctrl && !key.meta)
51
- openPreview(url);
167
+ return;
168
+ }
169
+ if (busy || finished)
170
+ return;
171
+ const shortcut = !key.ctrl && !key.meta ? actions.findIndex((item) => item.shortcut === key.name) : -1;
172
+ if (shortcut !== -1) {
173
+ selected = shortcut;
174
+ activate();
175
+ return;
176
+ }
177
+ if (key.name === "up")
178
+ selected = (selected - 1 + actions.length) % actions.length;
179
+ else if (key.name === "down")
180
+ selected = (selected + 1) % actions.length;
181
+ else if (key.name === "return") {
182
+ activate();
183
+ return;
184
+ } else
185
+ return;
186
+ render();
52
187
  };
53
188
  process.once("SIGINT", close);
54
189
  process.once("SIGTERM", close);
55
190
  process.once("exit", close);
56
191
  if (interactive) {
192
+ output.write("\x1B[?1049h\x1B[?25l");
193
+ output.on("resize", render);
57
194
  emitKeypressEvents(input);
58
195
  input.setRawMode(true);
59
196
  input.on("keypress", keypress);
60
197
  input.once("end", close);
61
198
  input.resume();
62
199
  }
63
- console.log(`
64
- ${link}
65
- `);
66
- if (interactive) {
67
- console.log(` shortcuts:
68
- ${accent("o")} - open in browser
69
- ctrl+c - exit
200
+ if (interactive)
201
+ render();
202
+ else
203
+ console.log(`
204
+ ${link(name)}
70
205
  `);
71
- }
72
206
  return { close, closed, interactive };
73
207
  }
208
+
209
+ // packages/cli/src/site/terminal.ts
210
+ function previewTerminal2(url, target) {
211
+ return previewTerminal(url, target, async () => {
212
+ const { startLanShare } = await import(new URL("../package/preview.js", import.meta.url).href);
213
+ return startLanShare(target);
214
+ });
215
+ }
74
216
  export {
75
217
  openPreview,
76
- previewTerminal
218
+ previewTerminal2 as previewTerminal
77
219
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kennel",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "Open-source pop-up previews for AI output: markdown, HTML artifacts, email, images, code, tables, notes, and calendars, from the terminal or your agent session.",
5
5
  "type": "module",
6
6
  "license": "MIT",